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
This commit is contained in:
Riley Bauer 2019-01-14 10:08:22 -08:00 committed by Kubernetes Prow Robot
parent df635af409
commit 504b89620e
6 changed files with 1864 additions and 919 deletions

View File

@ -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;

View File

@ -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<any>) =>
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(<SideNav page={RoutePage.PIPELINES} {...routerProps} />);
});
it('renders expanded state', () => {
localStorageHasKeySpy.mockImplementationOnce(() => false);
(window as any).innerWidth = wideWidth;
tree = shallow(<SideNav page={RoutePage.PIPELINES} {...routerProps} />);
expect(tree).toMatchSnapshot();
});
it('renders collapsed state', () => {
jest.spyOn(LocalStorage, 'hasKey').mockImplementationOnce(() => false);
localStorageHasKeySpy.mockImplementationOnce(() => false);
(window as any).innerWidth = narrowWidth;
const tree = shallow(<SideNav page={RoutePage.PIPELINES} {...routerProps} />);
tree = shallow(<SideNav page={RoutePage.PIPELINES} {...routerProps} />);
expect(tree).toMatchSnapshot();
});
it('renders Pipelines as active page', () => {
const tree = shallow(<SideNav page={RoutePage.PIPELINES} {...routerProps} />);
tree = shallow(<SideNav page={RoutePage.PIPELINES} {...routerProps} />);
expect(tree).toMatchSnapshot();
});
it('renders Pipelines as active when on PipelineDetails page', () => {
const tree = shallow(<SideNav page={RoutePage.PIPELINE_DETAILS} {...routerProps} />);
tree = shallow(<SideNav page={RoutePage.PIPELINE_DETAILS} {...routerProps} />);
expect(tree).toMatchSnapshot();
});
it('renders experiments as active page', () => {
const tree = shallow(<SideNav page={RoutePage.EXPERIMENTS} {...routerProps} />);
tree = shallow(<SideNav page={RoutePage.EXPERIMENTS} {...routerProps} />);
expect(tree).toMatchSnapshot();
});
it('renders experiments as active when on ExperimentDetails page', () => {
const tree = shallow(<SideNav page={RoutePage.EXPERIMENT_DETAILS} {...routerProps} />);
tree = shallow(<SideNav page={RoutePage.EXPERIMENT_DETAILS} {...routerProps} />);
expect(tree).toMatchSnapshot();
});
it('renders experiments as active page when on NewExperiment page', () => {
const tree = shallow(<SideNav page={RoutePage.NEW_EXPERIMENT} {...routerProps} />);
tree = shallow(<SideNav page={RoutePage.NEW_EXPERIMENT} {...routerProps} />);
expect(tree).toMatchSnapshot();
});
it('renders experiments as active page when on Compare page', () => {
const tree = shallow(<SideNav page={RoutePage.COMPARE} {...routerProps} />);
tree = shallow(<SideNav page={RoutePage.COMPARE} {...routerProps} />);
expect(tree).toMatchSnapshot();
});
it('renders experiments as active page when on AllRuns page', () => {
const tree = shallow(<SideNav page={RoutePage.RUNS} {...routerProps} />);
tree = shallow(<SideNav page={RoutePage.RUNS} {...routerProps} />);
expect(tree).toMatchSnapshot();
});
it('renders experiments as active page when on RunDetails page', () => {
const tree = shallow(<SideNav page={RoutePage.RUN_DETAILS} {...routerProps} />);
tree = shallow(<SideNav page={RoutePage.RUN_DETAILS} {...routerProps} />);
expect(tree).toMatchSnapshot();
});
it('renders experiments as active page when on RecurringRunDetails page', () => {
const tree = shallow(<SideNav page={RoutePage.RECURRING_RUN} {...routerProps} />);
tree = shallow(<SideNav page={RoutePage.RECURRING_RUN} {...routerProps} />);
expect(tree).toMatchSnapshot();
});
it('renders experiments as active page when on NewRun page', () => {
const tree = shallow(<SideNav page={RoutePage.NEW_RUN} {...routerProps} />);
tree = shallow(<SideNav page={RoutePage.NEW_RUN} {...routerProps} />);
expect(tree).toMatchSnapshot();
});
it('show jupyterhub link if accessible', () => {
const tree = shallow(<SideNav page={RoutePage.COMPARE} {...routerProps} />);
tree = shallow(<SideNav page={RoutePage.COMPARE} {...routerProps} />);
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(<SideNav page={RoutePage.COMPARE} {...routerProps} />);
tree = shallow(<SideNav page={RoutePage.COMPARE} {...routerProps} />);
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(<SideNav page={RoutePage.COMPARE} {...routerProps} />);
tree = shallow(<SideNav page={RoutePage.COMPARE} {...routerProps} />);
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(<SideNav page={RoutePage.COMPARE} {...routerProps} />);
tree = shallow(<SideNav page={RoutePage.COMPARE} {...routerProps} />);
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(<SideNav page={RoutePage.COMPARE} {...routerProps} />);
tree = shallow(<SideNav page={RoutePage.COMPARE} {...routerProps} />);
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(<SideNav page={RoutePage.COMPARE} {...routerProps} />);
tree = shallow(<SideNav page={RoutePage.COMPARE} {...routerProps} />);
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(<SideNav page={RoutePage.COMPARE} {...routerProps} />);
tree = shallow(<SideNav page={RoutePage.COMPARE} {...routerProps} />);
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(<SideNav page={RoutePage.COMPARE} {...routerProps} />);
tree = shallow(<SideNav page={RoutePage.COMPARE} {...routerProps} />);
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(<SideNav page={RoutePage.COMPARE} {...routerProps} />);
tree = shallow(<SideNav page={RoutePage.COMPARE} {...routerProps} />);
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(<SideNav page={RoutePage.PIPELINES} {...routerProps} />);
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(<SideNav page={RoutePage.PIPELINES} {...routerProps} />);
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(<SideNav page={RoutePage.PIPELINES} {...routerProps} />);
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(<SideNav page={RoutePage.PIPELINES} {...routerProps} />);
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(<SideNav page={RoutePage.PIPELINES} {...routerProps} />);
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(<SideNav page={RoutePage.PIPELINES} {...routerProps} />);
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(<SideNav page={RoutePage.PIPELINES} {...routerProps} />);
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(<SideNav page={RoutePage.PIPELINES} {...routerProps} />);
await TestUtils.flushPromises();
expect(tree.state('jupyterHubAvailable')).toEqual(false);
expect(consoleErrorSpy.mock.calls[0][0]).toBe('Failed to reach Jupyter Hub');
});
});

View File

@ -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<SideNavProps, SideNavState> {
export default class SideNav extends React.Component<SideNavProps, SideNavState> {
private _isMounted = true;
private readonly _AUTO_COLLAPSE_WIDTH = 800;
private readonly _HUB_ADDRESS = '/hub/';
@ -150,61 +185,115 @@ class SideNav extends React.Component<SideNavProps, SideNavState> {
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 (
<div id='sideNav' className={classes(css.root, commonCss.noShrink, collapsed && css.collapsedRoot)}>
<div className={classes(css.button, collapsed && css.collapsedButton, css.logo)}>
<KubeflowLogo color={iconColor.active} style={{ flexShrink: 0 }} />
<span className={classes(collapsed && css.collapsedLabel, css.label, css.logoLabel)}>
Kubeflow
</span>
<div id='sideNav' className={classes(css.root, commonCss.flexColumn, commonCss.noShrink, collapsed && css.collapsedRoot)}>
<div style={{ flexGrow: 1 }}>
<Tooltip title={'Kubeflow Pipelines'} enterDelay={300} placement={'right'}
disableFocusListener={!collapsed} disableHoverListener={!collapsed}
disableTouchListener={!collapsed}>
<Link id='kfpLogoBtn' to={RoutePage.PIPELINES} className={classes(css.button, collapsed && css.collapsedButton, css.logo, commonCss.unstyled)}>
<KubeflowLogo color={iconColor.active} style={{ flexShrink: 0 }} />
<span className={classes(collapsed && css.collapsedLabel, css.label, css.logoLabel)}>
Kubeflow
</span>
</Link>
</Tooltip>
<Tooltip title={'Pipeline List'} enterDelay={300} placement={'right-start'}
disableFocusListener={!collapsed} disableHoverListener={!collapsed}
disableTouchListener={!collapsed}>
<Link id='pipelinesBtn' to={RoutePage.PIPELINES} className={commonCss.unstyled}>
<Button className={classes(css.button,
page.startsWith(RoutePage.PIPELINES) && css.active,
collapsed && css.collapsedButton)}>
<PipelinesIcon color={page.startsWith(RoutePage.PIPELINES) ? iconColor.active : iconColor.inactive} />
<span className={classes(collapsed && css.collapsedLabel, css.label)}>Pipelines</span>
</Button>
</Link>
</Tooltip>
<Tooltip title={'Experiment List'} enterDelay={300} placement={'right-start'}
disableFocusListener={!collapsed} disableHoverListener={!collapsed}
disableTouchListener={!collapsed}>
<Link id='experimentsBtn' to={RoutePage.EXPERIMENTS} className={commonCss.unstyled}>
<Button className={
classes(
css.button,
this._highlightExperimentsButton(page) && css.active,
collapsed && css.collapsedButton)}>
<ExperimentsIcon color={this._highlightExperimentsButton(page) ? iconColor.active : iconColor.inactive} />
<span className={classes(collapsed && css.collapsedLabel, css.label)}>Experiments</span>
</Button>
</Link>
</Tooltip>
{this.state.jupyterHubAvailable && (
<Tooltip title={'Open Jupyter Notebook'} enterDelay={300} placement={'right-start'}
disableFocusListener={!collapsed} disableHoverListener={!collapsed}
disableTouchListener={!collapsed}>
<a id='jupyterhubBtn' href={this._HUB_ADDRESS} className={commonCss.unstyled} target='_blank'>
<Button className={
classes(css.button, collapsed && css.collapsedButton)}>
<JupyterhubIcon style={{ height: 20, width: 20 }} />
<span className={classes(collapsed && css.collapsedLabel, css.label)}>Notebooks</span>
<OpenInNewIcon className={classes(css.openInNewTabIcon, css.marginBottom8)} />
</Button>
</a>
</Tooltip>
)}
<hr className={classes(css.separator, collapsed && css.collapsedSeparator)} />
<IconButton className={classes(css.chevron, collapsed && css.collapsedChevron)}
onClick={this._toggleNavClicked.bind(this)}>
<ChevronLeftIcon />
</IconButton>
</div>
<Link id='pipelinesBtn' to={RoutePage.PIPELINES} className={commonCss.unstyled}>
<Button className={classes(css.button,
page.startsWith(RoutePage.PIPELINES) && css.active,
collapsed && css.collapsedButton)}>
<PipelinesIcon color={page.startsWith(RoutePage.PIPELINES) ? iconColor.active : iconColor.inactive} />
<span className={classes(collapsed && css.collapsedLabel, css.label)}>Pipelines</span>
</Button>
</Link>
<Link id='experimentsBtn' to={RoutePage.EXPERIMENTS} className={commonCss.unstyled}>
<Button className={
classes(
css.button,
this._highlightExperimentsButton(page) && css.active,
collapsed && css.collapsedButton)}>
<ExperimentsIcon color={this._highlightExperimentsButton(page) ? iconColor.active : iconColor.inactive} />
<span className={classes(collapsed && css.collapsedLabel, css.label)}>Experiments</span>
</Button>
</Link>
{this.state.jupyterHubAvailable && (
<a id='jupyterhubBtn' href={this._HUB_ADDRESS} className={commonCss.unstyled} target='_blank'>
<Button className={
classes(css.button, collapsed && css.collapsedButton)}>
<JupyterhubIcon style={{ height: 20, width: 20 }} />
<span className={classes(collapsed && css.collapsedLabel, css.label)}>Notebooks</span>
<OpenInNewIcon style={{ height: 12, width: 12, marginLeft: 5, marginBottom: 8 }} />
</Button>
</a>
{displayBuildInfo && (
<Tooltip title={'Build date: ' + displayBuildInfo.date} enterDelay={300} placement={'top-start'}>
<div className={classes(css.buildInfo, collapsed && css.buildInfoHidden)}>
<span>Build commit: </span>
<a href={displayBuildInfo.commitUrl} className={classes(css.buildHash, commonCss.unstyled)}
target='_blank'>
{displayBuildInfo.commitHash}
<OpenInNewIcon className={classes(css.openInNewTabIcon)} />
</a>
</div>
</Tooltip>
)}
<hr className={classes(css.separator, collapsed && css.collapsedSeparator)} />
<IconButton className={classes(css.chevron, collapsed && css.collapsedChevron)}
onClick={this._toggleNavClicked.bind(this)}>
<ChevronLeftIcon />
</IconButton>
</div >
);
}
@ -219,7 +308,7 @@ class SideNav extends React.Component<SideNavProps, SideNavState> {
}
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<SideNavProps, SideNavState> {
}
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<SideNavProps, SideNavState> {
this._toggleNavCollapsed(window.innerWidth < this._AUTO_COLLAPSE_WIDTH);
}
}
}
export default SideNav;
private setStateSafe(newState: Partial<SideNavState>, cb?: () => void): void {
if (this._isMounted) {
this.setState(newState as any, cb);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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 () => {

View File

@ -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<boolean> {
try {
const healthStats = await this._fetchAndParse<any>('/healthz', v1beta1Prefix);
return healthStats.apiServerReady;
} catch (_) {
return false;
}
public static async getBuildInfo(): Promise<BuildInfo> {
return await this._fetchAndParse<BuildInfo>('/healthz', v1beta1Prefix);
}
/**
* Verifies that Jupyter Hub is reachable.
*/
public static async isJupyterHubAvailable(): Promise<boolean> {
const response = await fetch('/hub/', { credentials: 'same-origin' });
return response ? response.ok : false;
}
/**