diff --git a/README.md b/README.md index 97a826b..2df4ef6 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,13 @@ The available settings for this service are: - `default_cpu_limit`: Default CPU limit of a user server; defaults to `None` - `machine_profiles`: Instead of entering directly the CPU and Memory value, `tljh-repo2docker` can be configured with pre-defined machine profiles and users can only choose from the available option; defaults to `[]` -Here is an example of registering `tljh_repo2docker`'s service with JupyterHub +This service requires the following scopes : `read:users`, `admin:servers` and `read:roles:users`. Here is an example of registering `tljh_repo2docker`'s service with JupyterHub ```python # jupyterhub_config.py from tljh_repo2docker import TLJH_R2D_ADMIN_SCOPE +import sys c.JupyterHub.services.extend( [ @@ -77,7 +78,7 @@ c.JupyterHub.load_roles = [ { "description": "Role for tljh_repo2docker service", "name": "tljh-repo2docker-service", - "scopes": ["read:users", "read:servers", "read:roles:users"], + "scopes": ["read:users", "admin:servers", "read:roles:users"], "services": ["tljh_repo2docker"], }, { @@ -99,6 +100,7 @@ Here is an example of the configuration # jupyterhub_config.py from tljh_repo2docker import TLJH_R2D_ADMIN_SCOPE +import sys c.JupyterHub.services.extend( [ diff --git a/jupyterhub_config.py b/jupyterhub_config.py index ec27fd9..05be037 100644 --- a/jupyterhub_config.py +++ b/jupyterhub_config.py @@ -14,6 +14,7 @@ tljh_custom_jupyterhub_config(c) + c.JupyterHub.authenticator_class = DummyAuthenticator c.JupyterHub.allow_named_servers = True @@ -37,8 +38,7 @@ "--machine_profiles", '{"label": "Medium", "cpu": 4, "memory": 4}', "--machine_profiles", - '{"label": "Large", "cpu": 8, "memory": 8}' - + '{"label": "Large", "cpu": 8, "memory": 8}', ], "oauth_no_confirm": True, "oauth_client_allowed_scopes": [ @@ -58,11 +58,11 @@ { "description": "Role for tljh_repo2docker service", "name": "tljh-repo2docker-service", - "scopes": ["read:users", "read:servers", "read:roles:users"], + "scopes": ["read:users", "read:roles:users", "admin:servers"], "services": ["tljh_repo2docker"], }, { - "name": 'tljh-repo2docker-service-admin', + "name": "tljh-repo2docker-service-admin", "users": ["alice"], "scopes": [TLJH_R2D_ADMIN_SCOPE], }, diff --git a/src/common/AxiosContext.tsx b/src/common/AxiosContext.tsx index 7fffa60..15fdd81 100644 --- a/src/common/AxiosContext.tsx +++ b/src/common/AxiosContext.tsx @@ -2,9 +2,8 @@ import { createContext, useContext } from 'react'; import { AxiosClient } from './axiosclient'; export const AxiosContext = createContext<{ - hubClient: AxiosClient; serviceClient: AxiosClient; -}>({ hubClient: new AxiosClient({}), serviceClient: new AxiosClient({}) }); +}>({ serviceClient: new AxiosClient({}) }); export const useAxios = () => { return useContext(AxiosContext); diff --git a/src/common/ButtonWithConfirm.tsx b/src/common/ButtonWithConfirm.tsx index 97557c8..df7946a 100644 --- a/src/common/ButtonWithConfirm.tsx +++ b/src/common/ButtonWithConfirm.tsx @@ -3,9 +3,8 @@ import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; -import CircularProgress from '@mui/material/CircularProgress'; -import Box from '@mui/material/Box'; import { Fragment, memo, useCallback, useState } from 'react'; +import { Loading } from './LoadingAnimation'; interface IButtonWithConfirm { buttonLabel: string; @@ -15,11 +14,7 @@ interface IButtonWithConfirm { okLabel?: string; cancelLabel?: string; } -const Loading = () => ( - - - -); + function _ButtonWithConfirm(props: IButtonWithConfirm) { const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); diff --git a/src/common/LoadingAnimation.tsx b/src/common/LoadingAnimation.tsx new file mode 100644 index 0000000..9d35aae --- /dev/null +++ b/src/common/LoadingAnimation.tsx @@ -0,0 +1,7 @@ +import CircularProgress from '@mui/material/CircularProgress'; +import Box from '@mui/material/Box'; +export const Loading = () => ( + + + +); diff --git a/src/common/axiosclient.ts b/src/common/axiosclient.ts index 3be8759..2d3c60e 100644 --- a/src/common/axiosclient.ts +++ b/src/common/axiosclient.ts @@ -2,8 +2,6 @@ import urlJoin from 'url-join'; import { encodeUriComponents } from './utils'; import axios, { AxiosInstance } from 'axios'; -export const API_PREFIX = 'api'; -export const SPAWN_PREFIX = 'spawn'; export class AxiosClient { constructor(options: AxiosClient.IOptions) { this._baseUrl = options.baseUrl ?? ''; @@ -15,15 +13,15 @@ export class AxiosClient { async request(args: { method: 'get' | 'post' | 'put' | 'option' | 'delete'; - prefix: 'api' | 'spawn'; path: string; query?: string; data?: { [key: string]: any } | FormData; + params?: { [key: string]: string }; }): Promise { - const { method, path } = args; - + const { method, path, params } = args; + const prefix = 'api'; const data = args.data ?? {}; - let url = urlJoin(args.prefix, encodeUriComponents(path)); + let url = urlJoin(prefix, encodeUriComponents(path)); if (args.query) { const sep = url.indexOf('?') === -1 ? '?' : '&'; url = `${url}${sep}${args.query}`; @@ -35,7 +33,8 @@ export class AxiosClient { const response = await this._axios.request({ method, url, - data + data, + params }); return response.data; } diff --git a/src/environments/App.tsx b/src/environments/App.tsx index 587cfd3..6a3779c 100644 --- a/src/environments/App.tsx +++ b/src/environments/App.tsx @@ -20,12 +20,6 @@ export interface IAppProps { export default function App(props: IAppProps) { const jhData = useJupyterhub(); - const hubClient = useMemo(() => { - const baseUrl = jhData.hubPrefix; - const xsrfToken = jhData.xsrfToken; - return new AxiosClient({ baseUrl, xsrfToken }); - }, [jhData]); - const serviceClient = useMemo(() => { const baseUrl = jhData.servicePrefix; const xsrfToken = jhData.xsrfToken; @@ -34,7 +28,7 @@ export default function App(props: IAppProps) { return ( - + void; + loading?: boolean; } function _EnvironmentList(props: IEnvironmentListProps) { @@ -111,6 +112,7 @@ function _EnvironmentList(props: IEnvironmentListProps) { return ( { const response = await axios.serviceClient.request({ method: 'delete', - prefix: API_PREFIX, path: ENV_PREFIX, data: { name: props.image } }); diff --git a/src/servers/App.tsx b/src/servers/App.tsx index 4eff3c2..d5b4f51 100644 --- a/src/servers/App.tsx +++ b/src/servers/App.tsx @@ -22,12 +22,6 @@ export interface IAppProps { export default function App(props: IAppProps) { const jhData = useJupyterhub(); - const hubClient = useMemo(() => { - const baseUrl = jhData.hubPrefix; - const xsrfToken = jhData.xsrfToken; - return new AxiosClient({ baseUrl, xsrfToken }); - }, [jhData]); - const serviceClient = useMemo(() => { const baseUrl = jhData.servicePrefix; const xsrfToken = jhData.xsrfToken; @@ -36,7 +30,7 @@ export default function App(props: IAppProps) { return ( - + (''); const handleOpen = () => { setOpen(true); @@ -65,24 +67,22 @@ function _NewServerDialog(props: INewServerDialogProps) { const createServer = useCallback(async () => { const imageName = props.images[rowSelectionModel[0] as number].image_name; - const data = new FormData(); - data.append('image', imageName); - let path = ''; - if (serverName.length > 0) { - path = `${jhData.user}/${serverName}`; - } else { - path = jhData.user; - } + const data: { [key: string]: string } = { + imageName, + userName: jhData.user, + serverName + }; try { - await axios.hubClient.request({ + setLoading(true); + await axios.serviceClient.request({ method: 'post', - prefix: SPAWN_PREFIX, - path, + path: SERVER_PREFIX, data }); window.location.reload(); } catch (e: any) { - console.error(e); + setLoading(false); + alert(e); } }, [serverName, rowSelectionModel, props.images, axios, jhData]); const disabled = useMemo(() => { @@ -106,6 +106,7 @@ function _NewServerDialog(props: INewServerDialogProps) { Server Options + {props.allowNamedServers && ( @@ -131,8 +132,10 @@ function _NewServerDialog(props: INewServerDialogProps) { selectable rowSelectionModel={rowSelectionModel} setRowSelectionModel={updateSelectedRow} + loading={loading} /> + - )} - - {!props.active && ( - - )} - - )} - {progress < 100 && ( - - - + {props.active && ( + )} + + {!props.active && } ); } diff --git a/src/servers/RemoveServerButton.tsx b/src/servers/RemoveServerButton.tsx index ebc2250..41ff42e 100644 --- a/src/servers/RemoveServerButton.tsx +++ b/src/servers/RemoveServerButton.tsx @@ -5,7 +5,7 @@ import { memo, useCallback } from 'react'; import { useAxios } from '../common/AxiosContext'; import { ButtonWithConfirm } from '../common/ButtonWithConfirm'; import { useJupyterhub } from '../common/JupyterhubContext'; -import { API_PREFIX } from '../common/axiosclient'; +import { SERVER_PREFIX } from './types'; interface IRemoveServerButton { server: string; @@ -15,24 +15,20 @@ function _RemoveServerButton(props: IRemoveServerButton) { const axios = useAxios(); const jhData = useJupyterhub(); const removeEnv = useCallback(async () => { - let path = ''; - if (props.server.length > 0) { - path = `users/${jhData.user}/servers/${props.server}`; - } else { - path = `users/${jhData.user}/server`; - } try { - await axios.hubClient.request({ + await axios.serviceClient.request({ method: 'delete', - prefix: API_PREFIX, - path, - data: { remove: props.server.length > 0 } + path: SERVER_PREFIX, + data: { + userName: jhData.user, + serverName: props.server + } }); window.location.reload(); } catch (e: any) { console.error(e); } - }, [props.server, axios, jhData]); + }, [props.server, axios, jhData.user]); return ( tp.List: ), (self.service_prefix, web.RedirectHandler, {"url": server_url}), (server_url, ServersHandler), + ( + url_path_join(self.service_prefix, r"api/servers"), + ServersAPIHandler, + ), ( url_path_join(self.service_prefix, r"environments"), EnvironmentsHandler, diff --git a/tljh_repo2docker/base.py b/tljh_repo2docker/base.py index 98fb7da..060b721 100644 --- a/tljh_repo2docker/base.py +++ b/tljh_repo2docker/base.py @@ -42,7 +42,7 @@ def client(self): api_token = os.environ.get("JUPYTERHUB_API_TOKEN", None) BaseHandler._client = AsyncClient( base_url=api_url, - headers={f"Authorization": f"Bearer {api_token}"}, + headers={"Authorization": f"Bearer {api_token}"}, ) return BaseHandler._client @@ -85,6 +85,7 @@ async def render_template(self, name: str, **kwargs) -> str: service_prefix=self.settings.get("service_prefix", "/"), hub_prefix=self.settings.get("hub_prefix", "/"), base_url=base_url, + logo_url=url_path_join(base_url, "hub", "home"), logout_url=self.settings.get( "logout_url", url_path_join(base_url, "logout") ), diff --git a/tljh_repo2docker/model.py b/tljh_repo2docker/model.py index ec4f5d6..603e5c1 100644 --- a/tljh_repo2docker/model.py +++ b/tljh_repo2docker/model.py @@ -18,15 +18,14 @@ def from_dict(self, kwargs_dict: dict): def all_spawners(self) -> list: sp = [] for server in self.servers.values(): - if len(server["name"]) > 0: + active = bool(server.get("pending", None) or server.get("ready", False)) + if active or len(server["name"]) > 0: sp.append( { "name": server.get("name", ""), "url": server.get("url", ""), "last_activity": server.get("last_activity", None), - "active": bool( - server.get("pending", None) or server.get("ready", False) - ), + "active": active, "user_options": server.get("user_options", None), } ) diff --git a/tljh_repo2docker/servers.py b/tljh_repo2docker/servers.py index 8a84c83..ea4abb2 100644 --- a/tljh_repo2docker/servers.py +++ b/tljh_repo2docker/servers.py @@ -17,7 +17,6 @@ async def get(self): user_data = await self.fetch_user() server_data = user_data.all_spawners() - named_server_limit = 0 result = self.render_template( "servers.html", diff --git a/tljh_repo2docker/servers_api.py b/tljh_repo2docker/servers_api.py new file mode 100644 index 0000000..7d30693 --- /dev/null +++ b/tljh_repo2docker/servers_api.py @@ -0,0 +1,55 @@ +from jupyterhub.utils import url_path_join +from tornado import web + +from .base import BaseHandler + + +class ServersAPIHandler(BaseHandler): + """ + Handler to manage single servers + """ + + @web.authenticated + async def post(self): + data = self.get_json_body() + image_name = data.get("imageName", None) + user_name = data.get("userName", None) + server_name = data.get("serverName", "") + if user_name != self.current_user["name"]: + raise web.HTTPError(403, "Unauthorized") + if not image_name: + raise web.HTTPError(400, "Missing image name") + + post_data = {"image": image_name} + + path = "" + if len(server_name) > 0: + path = url_path_join("users", user_name, "servers", server_name) + else: + path = url_path_join("users", user_name, "server") + try: + response = await self.client.post(path, json=post_data) + response.raise_for_status() + except Exception: + raise web.HTTPError(500, "Server error") + + @web.authenticated + async def delete(self): + data = self.get_json_body() + user_name = data.get("userName", None) + server_name = data.get("serverName", "") + if user_name != self.current_user["name"]: + raise web.HTTPError(403, "Unauthorized") + + path = "" + post_data = {} + if len(server_name) > 0: + path = url_path_join("users", user_name, "servers", server_name) + post_data = {"remove": True} + else: + path = url_path_join("users", user_name, "server") + try: + response = await self.client.request("DELETE", path, json=post_data) + response.raise_for_status() + except Exception: + raise web.HTTPError(500, "Server error")