diff --git a/backend/sophon/notebooks/views.py b/backend/sophon/notebooks/views.py index 4c2211d..ad762b2 100644 --- a/backend/sophon/notebooks/views.py +++ b/backend/sophon/notebooks/views.py @@ -39,6 +39,35 @@ class NotebooksViewSet(SophonGroupViewSet, metaclass=abc.ABCMeta): serializer = Serializer(notebook) return Response(serializer.data, status.HTTP_200_OK) + @action(["PATCH"], detail=True) + def lock(self, request: Request, **kwargs): + """ + Lock the `Notebook`. + + Note that this does nothing on the backend; it's only meant to be an indication for the frontend. + """ + notebook: Notebook = self.get_object() + if notebook.locked_by is None: + notebook.locked_by = self.request.user + notebook.save() + Serializer = notebook.get_access_serializer(request.user) + serializer = Serializer(notebook) + return Response(serializer.data, status.HTTP_200_OK) + + @action(["PATCH"], detail=True) + def unlock(self, request: Request, **kwargs): + """ + Unlock the `Notebook`. + + Note that this does nothing on the backend; it's only meant to be an indication for the frontend. + """ + notebook: Notebook = self.get_object() + notebook.locked_by = None + notebook.save() + Serializer = notebook.get_access_serializer(request.user) + serializer = Serializer(notebook) + return Response(serializer.data, status.HTTP_200_OK) + @action(["PATCH"], detail=True) def stop(self, request: Request, **kwargs): """ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6a5584b..41be85c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import {SophonDescriptionBox} from "./components/informative/SophonDescriptionBo import {InstanceDescriptionBox} from "./components/instance/InstanceDescriptionBox" import {InstanceFormBox} from "./components/instance/InstanceFormBox" import {InstanceRouter} from "./components/instance/InstanceRouter" +import {NotebookCreateBox} from "./components/notebook/NotebookCreateBox" import {NotebookListBox} from "./components/notebook/NotebookListBox" import {NotebookRouter} from "./components/notebook/NotebookRouter" import {DebugBox} from "./components/placeholder/DebugBox" @@ -28,6 +29,8 @@ import {AuthorizationProvider} from "./contexts/authorization" import {CacheProvider} from "./contexts/cache" import {GroupProvider} from "./contexts/group" import {InstanceProvider} from "./contexts/instance" +import {NotebookProvider} from "./contexts/notebook" +import {ProjectProvider} from "./contexts/project" import {ThemeProvider} from "./contexts/theme" @@ -67,17 +70,23 @@ function App({..._}: RouteComponentProps) { } selectedRoute={({selection}) => <> - - - <> - - } - selectedRoute={(props) => <> - - } - /> + + + <> + + + + } + selectedRoute={({selection}) => <> + + + + + } + /> + } /> diff --git a/frontend/src/components/notebook/NotebookCreateBox.tsx b/frontend/src/components/notebook/NotebookCreateBox.tsx new file mode 100644 index 0000000..a217309 --- /dev/null +++ b/frontend/src/components/notebook/NotebookCreateBox.tsx @@ -0,0 +1,120 @@ +import {Box, Details, Form, Idiomatic as I, useFormState} from "@steffo/bluelib-react" +import * as React from "react" +import {useAuthorizationContext} from "../../contexts/authorization" +import {useProjectContext} from "../../contexts/project" +import {ManagedResource, ManagedViewSet} from "../../hooks/useManagedViewSet" +import {SophonNotebook} from "../../types/SophonTypes" +import {Validators} from "../../utils/Validators" + + +export interface NotebookCreateBoxProps { + viewSet?: ManagedViewSet, + resource?: ManagedResource, +} + + +export function NotebookCreateBox({viewSet, resource}: NotebookCreateBoxProps): JSX.Element { + const authorization = useAuthorizationContext() + const project = useProjectContext() + + const name = + useFormState( + resource?.value.name ?? "", + Validators.notZeroLength, + ) + + const slug = + React.useMemo( + () => resource ? resource.value.slug : name.value.replaceAll(/[^A-Za-z0-9-]/g, "-").toLowerCase(), + [resource, name], + ) + + const image = + useFormState( + resource?.value.container_image ?? "steffo45/jupyterlab-docker-sophon", + Validators.alwaysValid, + ) + + // TODO: Fix this + const applyChanges = + React.useCallback( + async () => { + if(!project) { + return + } + + if(resource) { + await resource.update({ + name: name.value, + slug: slug, + container_image: image.value, + project: project?.value.slug, + }) + } + else { + await viewSet!.create({ + name: name.value, + slug: slug, + container_image: image.value, + project: project?.value.slug, + }) + } + }, + [viewSet, resource, name, slug, image, project], + ) + + const canApply = + React.useMemo( + () => name.validity === true && Boolean(authorization?.state.user?.username), + [name, authorization], + ) + + const hasError = + React.useMemo( + () => viewSet?.operationError || resource?.error, + [viewSet, resource], + ) + + return ( + +
+ + {resource ? <>Edit {resource.value.name} : "Create a new notebook"} + + +
+ + 0 ? true : undefined} + /> + + + + {resource ? "Edit" : "Create"} + + + +
+
+
+ ) +} diff --git a/frontend/src/components/notebook/NotebookDeleteButton.tsx b/frontend/src/components/notebook/NotebookDeleteButton.tsx new file mode 100644 index 0000000..c279c86 --- /dev/null +++ b/frontend/src/components/notebook/NotebookDeleteButton.tsx @@ -0,0 +1,40 @@ +import {faTrash} from "@fortawesome/free-solid-svg-icons" +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome" +import * as React from "react" +import {useAuthorizationContext} from "../../contexts/authorization" +import {useGroupContext} from "../../contexts/group" +import {ManagedResource} from "../../hooks/useManagedViewSet" +import {SophonNotebook} from "../../types/SophonTypes" +import {SafetyButton} from "../elements/SafetyButton" + + +export interface NotebookDeleteButtonProps { + resource: ManagedResource, +} + + +export function NotebookDeleteButton({resource}: NotebookDeleteButtonProps): JSX.Element | null { + const authorization = useAuthorizationContext() + const group = useGroupContext() + + if(!authorization) { + return null + } + if(!group) { + return null + } + if(!authorization.state.user) { + return null + } + if(!( + group.value.members.includes(authorization.state.user.id) || group.value.owner === authorization.state.user.id + )) { + return null + } + + return ( + resource.destroy()} disabled={resource.busy}> +  Delete + + ) +} diff --git a/frontend/src/components/notebook/NotebookLockButton.tsx b/frontend/src/components/notebook/NotebookLockButton.tsx new file mode 100644 index 0000000..85f5b34 --- /dev/null +++ b/frontend/src/components/notebook/NotebookLockButton.tsx @@ -0,0 +1,43 @@ +import {faLock} from "@fortawesome/free-solid-svg-icons" +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome" +import {Button} from "@steffo/bluelib-react" +import * as React from "react" +import {useAuthorizationContext} from "../../contexts/authorization" +import {useGroupContext} from "../../contexts/group" +import {ManagedResource} from "../../hooks/useManagedViewSet" +import {SophonNotebook} from "../../types/SophonTypes" + + +export interface NotebookLockButtonProps { + resource: ManagedResource, +} + + +export function NotebookLockButton({resource}: NotebookLockButtonProps): JSX.Element | null { + const authorization = useAuthorizationContext() + const group = useGroupContext() + + if(!authorization) { + return null + } + if(!group) { + return null + } + if(!authorization.state.user) { + return null + } + if(!( + group.value.members.includes(authorization.state.user.id) || group.value.owner === authorization.state.user.id + )) { + return null + } + if(resource.value.locked_by) { + return null + } + + return ( + + ) +} diff --git a/frontend/src/components/notebook/NotebookResourcePanel.tsx b/frontend/src/components/notebook/NotebookResourcePanel.tsx index 9979d91..6fe1e8b 100644 --- a/frontend/src/components/notebook/NotebookResourcePanel.tsx +++ b/frontend/src/components/notebook/NotebookResourcePanel.tsx @@ -1,10 +1,16 @@ import {faBook} from "@fortawesome/free-solid-svg-icons" import {FontAwesomeIcon} from "@fortawesome/react-fontawesome" import * as React from "react" +import {useCacheContext} from "../../contexts/cache" import {ManagedResource} from "../../hooks/useManagedViewSet" import {SophonNotebook} from "../../types/SophonTypes" import {Link} from "../elements/Link" import {ResourcePanel} from "../elements/ResourcePanel" +import {NotebookDeleteButton} from "./NotebookDeleteButton" +import {NotebookLockButton} from "./NotebookLockButton" +import {NotebookStartButton} from "./NotebookStartButton" +import {NotebookStopButton} from "./NotebookStopButton" +import {NotebookUnlockButton} from "./NotebookUnlockButton" export interface NotebookResourcePanelProps { @@ -13,6 +19,9 @@ export interface NotebookResourcePanelProps { export function NotebookResourcePanel({resource}: NotebookResourcePanelProps): JSX.Element { + const cache = useCacheContext() + + const locked_by = cache?.getUserById(resource.value.locked_by)?.value.username return ( @@ -25,10 +34,20 @@ export function NotebookResourcePanel({resource}: NotebookResourcePanelProps): J - + { + resource.value.locked_by + ? + `Locked by ${locked_by}` + : + null + } - + + + + + ) diff --git a/frontend/src/components/notebook/NotebookRouter.tsx b/frontend/src/components/notebook/NotebookRouter.tsx index f00b43e..1a50e15 100644 --- a/frontend/src/components/notebook/NotebookRouter.tsx +++ b/frontend/src/components/notebook/NotebookRouter.tsx @@ -16,7 +16,7 @@ export function NotebookRouter({projectPk, ...props}: ProjectRouterProps): JSX.E return ( (`/api/notebooks/by-project/${projectPk}`, "slug")} + viewSet={useManagedViewSet(`/api/notebooks/by-project/${projectPk}/`, "slug")} pathSegment={"notebook"} pkKey={"slug"} /> diff --git a/frontend/src/components/notebook/NotebookStartButton.tsx b/frontend/src/components/notebook/NotebookStartButton.tsx new file mode 100644 index 0000000..7939cc7 --- /dev/null +++ b/frontend/src/components/notebook/NotebookStartButton.tsx @@ -0,0 +1,46 @@ +import {faLightbulb} from "@fortawesome/free-solid-svg-icons" +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome" +import {Button} from "@steffo/bluelib-react" +import * as React from "react" +import {useAuthorizationContext} from "../../contexts/authorization" +import {useGroupContext} from "../../contexts/group" +import {ManagedResource} from "../../hooks/useManagedViewSet" +import {SophonNotebook} from "../../types/SophonTypes" + + +export interface NotebookStartButtonProps { + resource: ManagedResource, +} + + +export function NotebookStartButton({resource}: NotebookStartButtonProps): JSX.Element | null { + const authorization = useAuthorizationContext() + const group = useGroupContext() + + if(!authorization) { + return null + } + if(!group) { + return null + } + if(!authorization.state.user) { + return null + } + if(!( + group.value.members.includes(authorization.state.user.id) || group.value.owner === authorization.state.user.id + )) { + return null + } + if(resource.value.is_running) { + return null + } + if(resource.value.locked_by) { + return null + } + + return ( + + ) +} diff --git a/frontend/src/components/notebook/NotebookStopButton.tsx b/frontend/src/components/notebook/NotebookStopButton.tsx new file mode 100644 index 0000000..4c6d9ec --- /dev/null +++ b/frontend/src/components/notebook/NotebookStopButton.tsx @@ -0,0 +1,46 @@ +import {faLightbulb} from "@fortawesome/free-regular-svg-icons" +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome" +import {Button} from "@steffo/bluelib-react" +import * as React from "react" +import {useAuthorizationContext} from "../../contexts/authorization" +import {useGroupContext} from "../../contexts/group" +import {ManagedResource} from "../../hooks/useManagedViewSet" +import {SophonNotebook} from "../../types/SophonTypes" + + +export interface NotebookStopButtonProps { + resource: ManagedResource, +} + + +export function NotebookStopButton({resource}: NotebookStopButtonProps): JSX.Element | null { + const authorization = useAuthorizationContext() + const group = useGroupContext() + + if(!authorization) { + return null + } + if(!group) { + return null + } + if(!authorization.state.user) { + return null + } + if(!( + group.value.members.includes(authorization.state.user.id) || group.value.owner === authorization.state.user.id + )) { + return null + } + if(!resource.value.is_running) { + return null + } + if(resource.value.locked_by) { + return null + } + + return ( + + ) +} diff --git a/frontend/src/components/notebook/NotebookUnlockButton.tsx b/frontend/src/components/notebook/NotebookUnlockButton.tsx new file mode 100644 index 0000000..53f4ae3 --- /dev/null +++ b/frontend/src/components/notebook/NotebookUnlockButton.tsx @@ -0,0 +1,52 @@ +import {faLockOpen} from "@fortawesome/free-solid-svg-icons" +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome" +import {Button} from "@steffo/bluelib-react" +import * as React from "react" +import {useAuthorizationContext} from "../../contexts/authorization" +import {useGroupContext} from "../../contexts/group" +import {ManagedResource} from "../../hooks/useManagedViewSet" +import {SophonNotebook} from "../../types/SophonTypes" +import {SafetyButton} from "../elements/SafetyButton" + + +export interface NotebookUnlockButtonProps { + resource: ManagedResource, +} + + +export function NotebookUnlockButton({resource}: NotebookUnlockButtonProps): JSX.Element | null { + const authorization = useAuthorizationContext() + const group = useGroupContext() + + if(!authorization) { + return null + } + if(!group) { + return null + } + if(!authorization.state.user) { + return null + } + if(!( + group.value.members.includes(authorization.state.user.id) || group.value.owner === authorization.state.user.id + )) { + return null + } + if(!resource.value.locked_by) { + return null + } + + if(resource.value.locked_by === authorization.state.user.id) { + return ( + + ) + } + + return ( + resource.action("PATCH", "unlock", {})} disabled={resource.busy}> +  Unlock + + ) +} diff --git a/frontend/src/components/project/ProjectCreateBox.tsx b/frontend/src/components/project/ProjectCreateBox.tsx index 004743f..de935d8 100644 --- a/frontend/src/components/project/ProjectCreateBox.tsx +++ b/frontend/src/components/project/ProjectCreateBox.tsx @@ -65,7 +65,7 @@ export function ProjectCreateBox({viewSet, resource}: ProjectCreateBoxProps): JS return true } }, - [authorization, resource], + [authorization, group, resource], ) const applyChanges = @@ -96,7 +96,7 @@ export function ProjectCreateBox({viewSet, resource}: ProjectCreateBoxProps): JS const canApply = React.useMemo( () => name.validity === true && visibility.validity === true && Boolean(authorization?.state.user?.username) && group, - [name, visibility, authorization], + [name, visibility, authorization, group], ) const hasError = diff --git a/frontend/src/contexts/cache.tsx b/frontend/src/contexts/cache.tsx index d468d49..58749cd 100644 --- a/frontend/src/contexts/cache.tsx +++ b/frontend/src/contexts/cache.tsx @@ -52,8 +52,16 @@ export function CacheProvider({children}: WithChildren): JSX.Element { const getUserById = React.useCallback( - (id: number) => usersIdMap?.[id.toString()], - [usersIdMap], + (id: number) => { + if(!id) { + return undefined + } + if(!usersIdMap) { + return undefined + } + return usersIdMap[id.toString()] + }, + [usersIdMap] ) return diff --git a/frontend/src/contexts/notebook.tsx b/frontend/src/contexts/notebook.tsx new file mode 100644 index 0000000..daa138e --- /dev/null +++ b/frontend/src/contexts/notebook.tsx @@ -0,0 +1,21 @@ +import * as React from "react" +import {ManagedResource} from "../hooks/useManagedViewSet" +import {WithChildren, WithResource} from "../types/ExtraTypes" +import {SophonNotebook} from "../types/SophonTypes" + + +const notebookContext = React.createContext | undefined>(undefined) +const NotebookContext = notebookContext + + +/** + * Hook to access the {@link notebookContext}. + */ +export function useNotebookContext(): ManagedResource | undefined { + return React.useContext(notebookContext) +} + + +export function NotebookProvider({resource, children}: WithResource & WithChildren): JSX.Element { + return +} diff --git a/frontend/src/contexts/project.tsx b/frontend/src/contexts/project.tsx new file mode 100644 index 0000000..e6c022e --- /dev/null +++ b/frontend/src/contexts/project.tsx @@ -0,0 +1,21 @@ +import * as React from "react" +import {ManagedResource} from "../hooks/useManagedViewSet" +import {WithChildren, WithResource} from "../types/ExtraTypes" +import {SophonResearchProject} from "../types/SophonTypes" + + +const projectContext = React.createContext | undefined>(undefined) +const ProjectContext = projectContext + + +/** + * Hook to access the {@link projectContext}. + */ +export function useProjectContext(): ManagedResource | undefined { + return React.useContext(projectContext) +} + + +export function ProjectProvider({resource, children}: WithResource & WithChildren): JSX.Element { + return +}