Skip to content

Commit

Permalink
Update App Cards with Server Running Information (nebari-dev#306)
Browse files Browse the repository at this point in the history
* Add profiles to global state to support lookup. Added additional info and stop button to status chip.

* Refactor to simplify app card props.

* Add unit tests.

* Update to display server info on pinned apps.

* Updates to chip styling to better position stop button.
  • Loading branch information
jbouder authored May 29, 2024
1 parent 20383f0 commit 659f36f
Show file tree
Hide file tree
Showing 13 changed files with 253 additions and 83 deletions.
2 changes: 1 addition & 1 deletion jhub_apps/static/css/index.css

Large diffs are not rendered by default.

62 changes: 31 additions & 31 deletions jhub_apps/static/js/index.js

Large diffs are not rendered by default.

39 changes: 35 additions & 4 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import { ServerTypes } from './pages/server-types/server-types';
import {
currentNotification,
currentJhData as defaultJhData,
currentProfiles as defaultProfiles,
currentUser as defaultUser,
} from './store';
import { AppProfileProps } from './types/api';
import { JhData } from './types/jupyterhub';
import { UserState } from './types/user';
import axios from './utils/axios';
Expand All @@ -21,11 +23,16 @@ import { getJhData } from './utils/jupyterhub';
export const App = (): React.ReactElement => {
const [, setCurrentJhData] = useRecoilState<JhData>(defaultJhData);
const [, setCurrentUser] = useRecoilState<UserState | undefined>(defaultUser);
const [, setCurrentProfiles] =
useRecoilState<AppProfileProps[]>(defaultProfiles);
const [notification, setNotification] = useRecoilState<string | undefined>(
currentNotification,
);

const { error, data: userData } = useQuery<UserState, { message: string }>({
const { error: userError, data: userData } = useQuery<
UserState,
{ message: string }
>({
queryKey: ['user-state'],
queryFn: () =>
axios
Expand All @@ -38,11 +45,29 @@ export const App = (): React.ReactElement => {
}),
});

const { error: profileError, data: profileData } = useQuery<
AppProfileProps[],
{ message: string }
>({
queryKey: ['server-types'],
queryFn: () =>
axios
.get('/spawner-profiles/')
.then((response) => {
return response.data;
})
.then((data) => {
return data;
}),
});

useEffect(() => {
if (error) {
setNotification(error.message);
if (userError) {
setNotification(userError.message);
} else if (profileError) {
setNotification(profileError.message);
}
}, [error, setNotification]);
}, [userError, profileError, setNotification]);

useEffect(() => {
setCurrentJhData(getJhData());
Expand All @@ -54,6 +79,12 @@ export const App = (): React.ReactElement => {
}
}, [userData, setCurrentUser]);

useEffect(() => {
if (profileData) {
setCurrentProfiles([...profileData]);
}
}, [profileData, setCurrentProfiles]);

return (
<div>
<Navigation />
Expand Down
13 changes: 12 additions & 1 deletion ui/src/components/app-card/app-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ import Typography from '@mui/material/Typography';
import { StatusChip } from '@src/components';
import { API_BASE_URL } from '@src/utils/constants';

import { AppProfileProps } from '@src/types/api';
import { JhApp } from '@src/types/jupyterhub';
import React, { useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
import {
currentApp,
currentNotification,
currentProfiles as defaultProfiles,
isDeleteOpen,
isStartOpen,
isStopOpen,
Expand Down Expand Up @@ -56,6 +58,7 @@ export const AppCard = ({
app,
}: AppCardProps): React.ReactElement => {
const [appStatus, setAppStatus] = useState('');
const [currentProfiles] = useRecoilState<AppProfileProps[]>(defaultProfiles);
const [, setCurrentApp] = useRecoilState<JhApp | undefined>(currentApp);
const [, setNotification] = useRecoilState<string | undefined>(
currentNotification,
Expand Down Expand Up @@ -169,6 +172,10 @@ export const AppCard = ({
}
};

const getProfileData = (profile: string) => {
return currentProfiles.find((p) => p.slug === profile)?.display_name || '';
};

return (
<Box
className={`card ${isAppCard ? '' : 'service'}`}
Expand All @@ -184,7 +191,11 @@ export const AppCard = ({
<>
<div className="chip-container">
<div className="menu-chip">
<StatusChip status={appStatus} />
<StatusChip
status={appStatus}
additionalInfo={getProfileData(app?.profile || '')}
app={app}
/>
</div>
</div>
<ContextMenu
Expand Down
7 changes: 7 additions & 0 deletions ui/src/components/status-chip/status-chip.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.card-content-header .chip-container span.MuiChip-label {
padding-right: 2px;
}

.card-content-header .chip-container .chip-base span.MuiChip-label {
padding-right: 8px;
}
78 changes: 69 additions & 9 deletions ui/src/components/status-chip/status-chip.test.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,92 @@
import { render } from '@testing-library/react';
import { apps } from '@src/data/api';
import '@testing-library/jest-dom';
import { render, waitFor } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { StatusChip } from '..';

describe('StatusChip', () => {
test('renders default successfully', () => {
const { baseElement } = render(<StatusChip status="Ready" />);
test('renders default chip successfully', () => {
const { baseElement } = render(
<RecoilRoot>
<StatusChip status="Ready" />
</RecoilRoot>,
);
const chip = baseElement.querySelector('.MuiChip-root');
expect(chip).toBeTruthy();
expect(chip?.textContent).toBe('Ready');
});

test('renders default successfully', () => {
const { baseElement } = render(<StatusChip status="Pending" />);
test('renders pending chip successfully', () => {
const { baseElement } = render(
<RecoilRoot>
<StatusChip status="Pending" />
</RecoilRoot>,
);
const chip = baseElement.querySelector('.MuiChip-root');
expect(chip).toBeTruthy();
expect(chip?.textContent).toBe('Pending');
});

test('renders default successfully', () => {
const { baseElement } = render(<StatusChip status="Running" />);
test('renders running chip successfully', () => {
const { baseElement } = render(
<RecoilRoot>
<StatusChip status="Running" />
</RecoilRoot>,
);
const chip = baseElement.querySelector('.MuiChip-root');
expect(chip).toBeTruthy();
expect(chip?.textContent).toBe('Running');
});

test('renders default successfully', () => {
const { baseElement } = render(<StatusChip status="Unknown" />);
test('renders unknown chip successfully', () => {
const { baseElement } = render(
<RecoilRoot>
<StatusChip status="Unknown" />
</RecoilRoot>,
);
const chip = baseElement.querySelector('.MuiChip-root');
expect(chip).toBeTruthy();
expect(chip?.textContent).toBe('Unknown');
});

test('renders running chip with additional info', () => {
const { baseElement } = render(
<RecoilRoot>
<StatusChip status="Running" additionalInfo="small" app={apps[0]} />
</RecoilRoot>,
);
const chip = baseElement.querySelector('.MuiChip-root');
expect(chip).toBeTruthy();
expect(chip?.textContent).toBe('Running on small');
});

test('renders shared app chip running with no additional info', () => {
const newApp = { ...apps[0], shared: true };
const { baseElement } = render(
<RecoilRoot>
<StatusChip status="Running" additionalInfo="small" app={newApp} />
</RecoilRoot>,
);
const chip = baseElement.querySelector('.MuiChip-root');
expect(chip).toBeTruthy();
expect(chip?.textContent).toBe('Running');
});

test('simulates stopping app from chip button', async () => {
const { baseElement } = render(
<RecoilRoot>
<StatusChip status="Running" additionalInfo="small" app={apps[0]} />
</RecoilRoot>,
);
const stopButton = baseElement.querySelector(
'.MuiIconButton-root',
) as HTMLButtonElement;
if (stopButton) {
stopButton.click();
}
waitFor(() => {
const stopModal = baseElement.querySelector('.MuiDialog-root');
expect(stopModal).toBeTruthy();
});
});
});
85 changes: 77 additions & 8 deletions ui/src/components/status-chip/status-chip.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { Chip } from '@mui/material';
import StopCircleRoundedIcon from '@mui/icons-material/StopCircleRounded';
import { Chip, IconButton } from '@mui/material';
import { JhApp } from '@src/types/jupyterhub';
import { useRecoilState } from 'recoil';
import { currentApp, isStopOpen } from '../../store';
import './status-chip.css';

interface StatusChipProps {
status: string;
additionalInfo?: string;
app?: JhApp;
size?: 'small' | 'medium';
}
const getStatusStyles = (status: string) => {
Expand Down Expand Up @@ -38,12 +46,73 @@ const getStatusStyles = (status: string) => {

export const StatusChip = ({
status,
additionalInfo,
app,
size = 'small',
}: StatusChipProps): React.ReactElement => (
<Chip
label={status || 'Default'}
size={size}
sx={{ fontWeight: 600, fontSize: '12px', ...getStatusStyles(status) }}
/>
);
}: StatusChipProps): React.ReactElement => {
const [, setCurrentApp] = useRecoilState<JhApp | undefined>(currentApp);
const [, setIsStopOpen] = useRecoilState<boolean>(isStopOpen);

const getLabel = () => {
if (status === 'Running' && additionalInfo) {
return (
<>
{app && !app.shared ? (
<>
<span
className="chip-label-info"
style={{ position: 'relative', top: '1px' }}
>
{status} on {additionalInfo}
</span>
<IconButton
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setCurrentApp(app);
setIsStopOpen(true);
}}
aria-label="Stop"
sx={{
pl: 0,
position: 'relative',
top: 0,
left: '6px',
}}
color="inherit"
disabled={app.shared}
>
<StopCircleRoundedIcon
sx={{
fontSize: '16px',
}}
/>
</IconButton>
</>
) : (
<span>{status}</span>
)}
</>
);
}
return status || 'Default';
};

return (
<Chip
label={getLabel()}
className={
status !== 'Running' || !additionalInfo || app?.shared
? 'chip-base'
: ''
}
size={size}
sx={{
fontWeight: 600,
fontSize: '12px',
...getStatusStyles(status),
}}
/>
);
};
export default StatusChip;
8 changes: 5 additions & 3 deletions ui/src/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ export const environments = ['env-1', 'env-2', 'env-3', 'env-4', 'env-5'];

export const profiles: AppProfileProps[] = [
{
display_name: 'Small',
display_name: 'Small Instance',
slug: 'small0',
description: 'Stable environment with 1 CPU / 4GB RAM',
},
{
display_name: 'Small',
display_name: 'Small Instance',
slug: 'small1',
description: 'Stable environment with 2 CPU / 8GB RAM',
},
Expand Down Expand Up @@ -93,7 +93,9 @@ export const serverApps = {
name: '',
url: '/user/test',
ready: true,
user_options: {},
user_options: {
profile: 'small1',
},
last_activity: getMockDate(0),
},
{
Expand Down
7 changes: 1 addition & 6 deletions ui/src/pages/home/apps-section/app-grid/app-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,11 @@ export const AppGrid = ({ apps }: AppsGridProps): React.ReactElement => {
<>
{apps.map((app: JhApp, index: number) => (
<AppCard
id={app.id}
{...app}
key={`app-${app.id}-${index}`}
title={app.name}
description={app.description}
thumbnail={app.thumbnail}
framework={app.framework}
url={app.url}
serverStatus={app.status}
lastModified={app.last_activity}
username={app.username}
isPublic={app.public}
isShared={app.shared}
app={app}
Expand Down
Loading

0 comments on commit 659f36f

Please sign in to comment.