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:
parent
df635af409
commit
504b89620e
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue