mirror of
https://github.com/Steffo99/sophon.git
synced 2024-12-22 14:54:22 +00:00
✨ Implement Notebook app (#65)
* ✨ Start notebooks app * 🔧 Fix nullable fields for notebooks * 🔧 Display user-friendly name for `Notebook`s * 🔧 Allow filtering in the notebook admin page * 🗒 Improve README * 🗒 Improve README again * ⬆ Add bluelib to the dependencies of the frontend * 🧹 Prepare a good frontend base for development * ✨ Port and improve useStorageState Original: https://github.com/pds-nest/nest/blob/main/nest_frontend/hooks/useLocalStorageState.js * 🧹 Remove React logo * ⬆ Add `docker` to the dependencies * ⬆ Add `axios` to the dependencies * 🔨 Mark `src` as sources root * ✨ Add API routes to view Notebooks * 🔧 Use a router for the `by-project` route * 🐛 Fix deletion failing on `SophonViewSet` * 🔧 Abstract notebook methods * ✨ Create a base docker client * 🚧 Proof of concept for notebook starter * 📔 Document the contents of the Django apps * 🚧 Incomplete container implementation * 🚧 Working container implementation * 💥 Leftovers from an experiment * ✨ Correct implementation of the proxy configuration (Apache config file is still missing) * 💥 Improve code * 💥 Improve more things * 🔧 Remove duplicated `/project` in project app urls * ✨ Add basic Apache proxy config file * 🔧 User should have sudo access on the notebook * ✨ Implement the Internet access field (currently ignored) * 🧹 Cleanup code
This commit is contained in:
parent
8614c5714b
commit
4a4824395a
48 changed files with 1474 additions and 231 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
proxy.dbm
|
|
@ -1,6 +1,7 @@
|
|||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="HttpUrlsUsage" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="InconsistentLineSeparators" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="JupyterPackageInspection" enabled="true" level="ERROR" enabled_by_default="true" />
|
||||
<inspection_tool class="LessResolvedByNameOnly" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<component name="ClojureProjectResolveSettings">
|
||||
<currentScheme>IDE</currentScheme>
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_15" project-jdk-name="Poetry (backend)" project-jdk-type="Python SDK">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_15">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
<component name="PythonCompatibilityInspectionAdvertiser">
|
||||
|
|
|
@ -9,6 +9,8 @@
|
|||
<env name="DJANGO_SECRET_KEY" value="change-me-in-production" />
|
||||
<env name="DJANGO_DEBUG" value="1" />
|
||||
<env name="DJANGO_TIME_ZONE" value="CET" />
|
||||
<env name="APACHE_PROXY_BASE_DOMAIN" value="dev.sophon.steffo.eu" />
|
||||
<env name="APACHE_PROXY_HTTP_PROTOCOL" value="http" />
|
||||
</envs>
|
||||
<option name="SDK_HOME" value="$USER_HOME$/.cache/pypoetry/virtualenvs/sophon-oyAYqTyT-py3.9/bin/python" />
|
||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||
|
|
|
@ -15,12 +15,12 @@ The project consists of a **single-page-app with React** on the frontend and a *
|
|||
|
||||
Development progress is tracked on [issue #20](https://github.com/Steffo99/sophon/issues/20).
|
||||
|
||||
Development is currently focusing on the **backend**.
|
||||
|
||||
### Tools
|
||||
|
||||
Sophon is being developed using [IntelliJ IDEA Ultimate](https://www.jetbrains.com/idea/): its metadata is included in the `.idea` directory so that the code style and tools are consistent across all machines used during the development.
|
||||
|
||||
Run configurations for *running the backend*, *testing the backend* and *running the frontend* are included.
|
||||
|
||||
### Commits
|
||||
|
||||
Commits names are prefixed with a variant of [Gitmoji](https://gitmoji.dev/) which follows roughly this legend:
|
||||
|
|
61
backend/poetry.lock
generated
61
backend/poetry.lock
generated
|
@ -95,6 +95,23 @@ python-versions = ">=3.5"
|
|||
[package.dependencies]
|
||||
django = ">=2.2"
|
||||
|
||||
[[package]]
|
||||
name = "docker"
|
||||
version = "5.0.2"
|
||||
description = "A Python library for the Docker Engine API."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
pywin32 = {version = "227", markers = "sys_platform == \"win32\""}
|
||||
requests = ">=2.14.2,<2.18.0 || >2.18.0"
|
||||
websocket-client = ">=0.32.0"
|
||||
|
||||
[package.extras]
|
||||
ssh = ["paramiko (>=2.4.2)"]
|
||||
tls = ["pyOpenSSL (>=17.5.0)", "cryptography (>=3.4.7)", "idna (>=2.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "2.10"
|
||||
|
@ -238,6 +255,14 @@ category = "main"
|
|||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "pywin32"
|
||||
version = "227"
|
||||
description = "Python for Window Extensions"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.25.1"
|
||||
|
@ -285,10 +310,22 @@ secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "cer
|
|||
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
brotli = ["brotlipy (>=0.6.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "websocket-client"
|
||||
version = "1.2.1"
|
||||
description = "WebSocket client for Python with low level API options"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.extras]
|
||||
optional = ["python-socks", "wsaccel"]
|
||||
test = ["websockets"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.9"
|
||||
content-hash = "d14b706e9bdac9f5a747a783482242a12d2881cc08a6a53b46f3985562b20ed8"
|
||||
content-hash = "b0309f34f6dd10542d9c6bb8846a337f82e3dfc48ea46865905ea76dcf4891a2"
|
||||
|
||||
[metadata.files]
|
||||
asgiref = [
|
||||
|
@ -327,6 +364,10 @@ djangorestframework = [
|
|||
{file = "djangorestframework-3.12.4-py3-none-any.whl", hash = "sha256:6d1d59f623a5ad0509fe0d6bfe93cbdfe17b8116ebc8eda86d45f6e16e819aaf"},
|
||||
{file = "djangorestframework-3.12.4.tar.gz", hash = "sha256:f747949a8ddac876e879190df194b925c177cdeb725a099db1460872f7c0a7f2"},
|
||||
]
|
||||
docker = [
|
||||
{file = "docker-5.0.2-py2.py3-none-any.whl", hash = "sha256:9b17f0723d83c1f3418d2aa17bf90b24dbe97deda06208dd4262fa30a6ee87eb"},
|
||||
{file = "docker-5.0.2.tar.gz", hash = "sha256:21ec4998e90dff7a7aaaa098ca8d839c7de412b89e6f6c30908372d58fecf663"},
|
||||
]
|
||||
idna = [
|
||||
{file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
|
||||
{file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
|
||||
|
@ -492,6 +533,20 @@ pytz = [
|
|||
{file = "pytz-2021.1-py2.py3-none-any.whl", hash = "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"},
|
||||
{file = "pytz-2021.1.tar.gz", hash = "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da"},
|
||||
]
|
||||
pywin32 = [
|
||||
{file = "pywin32-227-cp27-cp27m-win32.whl", hash = "sha256:371fcc39416d736401f0274dd64c2302728c9e034808e37381b5e1b22be4a6b0"},
|
||||
{file = "pywin32-227-cp27-cp27m-win_amd64.whl", hash = "sha256:4cdad3e84191194ea6d0dd1b1b9bdda574ff563177d2adf2b4efec2a244fa116"},
|
||||
{file = "pywin32-227-cp35-cp35m-win32.whl", hash = "sha256:f4c5be1a293bae0076d93c88f37ee8da68136744588bc5e2be2f299a34ceb7aa"},
|
||||
{file = "pywin32-227-cp35-cp35m-win_amd64.whl", hash = "sha256:a929a4af626e530383a579431b70e512e736e9588106715215bf685a3ea508d4"},
|
||||
{file = "pywin32-227-cp36-cp36m-win32.whl", hash = "sha256:300a2db938e98c3e7e2093e4491439e62287d0d493fe07cce110db070b54c0be"},
|
||||
{file = "pywin32-227-cp36-cp36m-win_amd64.whl", hash = "sha256:9b31e009564fb95db160f154e2aa195ed66bcc4c058ed72850d047141b36f3a2"},
|
||||
{file = "pywin32-227-cp37-cp37m-win32.whl", hash = "sha256:47a3c7551376a865dd8d095a98deba954a98f326c6fe3c72d8726ca6e6b15507"},
|
||||
{file = "pywin32-227-cp37-cp37m-win_amd64.whl", hash = "sha256:31f88a89139cb2adc40f8f0e65ee56a8c585f629974f9e07622ba80199057511"},
|
||||
{file = "pywin32-227-cp38-cp38-win32.whl", hash = "sha256:7f18199fbf29ca99dff10e1f09451582ae9e372a892ff03a28528a24d55875bc"},
|
||||
{file = "pywin32-227-cp38-cp38-win_amd64.whl", hash = "sha256:7c1ae32c489dc012930787f06244426f8356e129184a02c25aef163917ce158e"},
|
||||
{file = "pywin32-227-cp39-cp39-win32.whl", hash = "sha256:c054c52ba46e7eb6b7d7dfae4dbd987a1bb48ee86debe3f245a2884ece46e295"},
|
||||
{file = "pywin32-227-cp39-cp39-win_amd64.whl", hash = "sha256:f27cec5e7f588c3d1051651830ecc00294f90728d19c3bf6916e6dba93ea357c"},
|
||||
]
|
||||
requests = [
|
||||
{file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"},
|
||||
{file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"},
|
||||
|
@ -508,3 +563,7 @@ urllib3 = [
|
|||
{file = "urllib3-1.26.4-py2.py3-none-any.whl", hash = "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df"},
|
||||
{file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"},
|
||||
]
|
||||
websocket-client = [
|
||||
{file = "websocket-client-1.2.1.tar.gz", hash = "sha256:8dfb715d8a992f5712fff8c843adae94e22b22a99b2c5e6b0ec4a1a981cc4e0d"},
|
||||
{file = "websocket_client-1.2.1-py2.py3-none-any.whl", hash = "sha256:0133d2f784858e59959ce82ddac316634229da55b498aac311f1620567a710ec"},
|
||||
]
|
||||
|
|
|
@ -16,6 +16,7 @@ pydantic = "~1.7.3"
|
|||
django-pam = "^2.0.0"
|
||||
django-colorfield = "^0.4.2"
|
||||
deprecation = "^2.1.0"
|
||||
docker = "^5.0.2"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
The :mod:`sophon.core` module provides the base Sophon functionality, such as users and research groups.
|
||||
"""
|
|
@ -141,7 +141,7 @@ class SophonViewSet(ModelViewSet, metaclass=abc.ABCMeta):
|
|||
except HTTPException as e:
|
||||
return e.as_response()
|
||||
|
||||
self.perform_destroy(instance)
|
||||
instance.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def get_serializer_class(self):
|
||||
|
|
21
backend/sophon/notebooks/__init__.py
Normal file
21
backend/sophon/notebooks/__init__.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
"""
|
||||
The :mod:`sophon.notebooks` module provides the JupyterLab connection and functionality.
|
||||
|
||||
It depends on the following :mod:`django` apps:
|
||||
|
||||
- `sophon.core`
|
||||
- `sophon.projects`
|
||||
|
||||
It can be configured with the following :data:`os.environ` keys:
|
||||
|
||||
- ``DOCKER_HOST``: The URL to the Docker host.
|
||||
- ``DOCKER_TLS_VERIFY``: Verify the host against a CA certificate.
|
||||
- ``DOCKER_CERT_PATH``: A path to a directory containing TLS certificates to use when connecting to the Docker host.
|
||||
- ``APACHE_PROXY_EXPRESS_DBM``: The filename of the ``proxy_express`` DBM file to write to.
|
||||
- ``APACHE_PROXY_BASE_DOMAIN``: The base domain for virtualhost reverse proxying.
|
||||
- ``APACHE_PROXY_HTTP_PROTOCOL``: The http_protocol that Apache uses to make the containers available to the public.
|
||||
- ``APACHE_PROXY_WS_PROTOCOL``: The http_protocol that Apache uses to make the Jupyter websocket available to the public.
|
||||
- ``SOPHON_CONTAINER_PREFIX``: The prefix added to the names of notebooks' containers will have.
|
||||
- ``SOPHON_VOLUME_PREFIX``: The prefix added to the names of notebooks' volumes will have.
|
||||
- ``SOPHON_NETWORK_PREFIX``: The prefix added to the names of notebooks' volumes will have.
|
||||
"""
|
61
backend/sophon/notebooks/admin.py
Normal file
61
backend/sophon/notebooks/admin.py
Normal file
|
@ -0,0 +1,61 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from sophon.core.admin import SophonAdmin
|
||||
from . import models
|
||||
|
||||
|
||||
@admin.register(models.Notebook)
|
||||
class NotebookAdmin(SophonAdmin):
|
||||
list_display = (
|
||||
"slug",
|
||||
"name",
|
||||
"project",
|
||||
"locked_by",
|
||||
"container_image",
|
||||
"container_id",
|
||||
"port",
|
||||
)
|
||||
|
||||
list_filter = (
|
||||
"container_image",
|
||||
)
|
||||
|
||||
ordering = (
|
||||
"slug",
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
None, {
|
||||
"fields": (
|
||||
"slug",
|
||||
"name",
|
||||
"project",
|
||||
"locked_by",
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Docker", {
|
||||
"fields": (
|
||||
"container_image",
|
||||
"container_id",
|
||||
"internet_access",
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Proxy", {
|
||||
"fields": (
|
||||
"port",
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Jupyter", {
|
||||
"fields": (
|
||||
"jupyter_token",
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
20
backend/sophon/notebooks/apache.conf
Normal file
20
backend/sophon/notebooks/apache.conf
Normal file
|
@ -0,0 +1,20 @@
|
|||
# Log rewrite at the maximum level
|
||||
# DO NOT ENABLE THIS IN PRODUCTION OR YOUR LOGS WILL BE FLOODED!
|
||||
# LogLevel "rewrite:trace8"
|
||||
|
||||
# Enable rewriting
|
||||
RewriteEngine on
|
||||
RewriteMap "sophonproxy" "dbm=gdbm:/mnt/tebi/ext4/workspace/sophon/proxy.dbm"
|
||||
|
||||
# Preserve host
|
||||
ProxyPreserveHost on
|
||||
|
||||
# Proxy websockets
|
||||
RewriteCond "%{HTTP_HOST}" ".dev.sophon.steffo.eu$" [NC]
|
||||
RewriteCond "%{HTTP:Connection}" "Upgrade" [NC]
|
||||
RewriteCond "%{HTTP:Upgrade}" "websocket" [NC]
|
||||
RewriteRule "/(.*)" "ws://${sophonproxy:%{HTTP_HOST}}/$1" [P,L]
|
||||
|
||||
# Proxy regular requests
|
||||
RewriteCond "%{HTTP_HOST}" ".dev.sophon.steffo.eu$" [NC]
|
||||
RewriteRule "/(.*)" "http://${sophonproxy:%{HTTP_HOST}}/$1" [P,L]
|
69
backend/sophon/notebooks/apache.py
Normal file
69
backend/sophon/notebooks/apache.py
Normal file
|
@ -0,0 +1,69 @@
|
|||
import dbm.gnu
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import typing as t
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
db_name = os.environ.get("APACHE_PROXY_EXPRESS_DBM", "proxy.dbm")
|
||||
base_domain = os.environ["APACHE_PROXY_BASE_DOMAIN"]
|
||||
http_protocol = os.environ.get("APACHE_PROXY_HTTP_PROTOCOL", "https")
|
||||
|
||||
|
||||
class ApacheDB:
|
||||
def __init__(self, filename: str):
|
||||
self.filename: str = filename
|
||||
log.debug(f"{self.filename}: Initializing...")
|
||||
with dbm.open(self.filename, "c"):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def convert_to_bytes(item: t.Union[str, bytes]) -> bytes:
|
||||
if isinstance(item, str):
|
||||
log.debug(f"Encoding {item!r} as ASCII...")
|
||||
item = item.encode("ascii")
|
||||
return item
|
||||
|
||||
def __getitem__(self, key: t.Union[str, bytes]) -> bytes:
|
||||
key = self.convert_to_bytes(key)
|
||||
log.debug(f"{self.filename}: Getting {key!r}...")
|
||||
with dbm.open(self.filename, "r") as adb:
|
||||
return adb[key]
|
||||
|
||||
def __setitem__(self, key: bytes, value: bytes) -> None:
|
||||
key = self.convert_to_bytes(key)
|
||||
value = self.convert_to_bytes(value)
|
||||
log.debug(f"{self.filename}: Setting {key!r} → {value!r}...")
|
||||
with dbm.open(self.filename, "w") as adb:
|
||||
adb[key] = value
|
||||
|
||||
def __delitem__(self, key):
|
||||
key = self.convert_to_bytes(key)
|
||||
log.debug(f"{self.filename}: Deleting {key!r}...")
|
||||
with dbm.open(self.filename, "w") as adb:
|
||||
del adb[key]
|
||||
|
||||
|
||||
log.info(f"Creating proxy_express database: {db_name}")
|
||||
db = ApacheDB(db_name)
|
||||
log.info(f"Created proxy_express database!")
|
||||
|
||||
|
||||
def get_ephemeral_port() -> int:
|
||||
"""
|
||||
Request a free TCP port from the operating system by opening and immediately closing a TCP socket.
|
||||
|
||||
:return: A free port number.
|
||||
|
||||
.. warning:: Prone to race conditions, be sure to bind something to the obtained port as soon as it is retrieved!
|
||||
|
||||
.. seealso:: https://stackoverflow.com/a/36331860/4334568
|
||||
"""
|
||||
|
||||
sock: socket.socket = socket.socket()
|
||||
sock.bind(("localhost", 0))
|
||||
|
||||
port: int
|
||||
_, port = sock.getsockname()
|
||||
|
||||
return port
|
6
backend/sophon/notebooks/apps.py
Normal file
6
backend/sophon/notebooks/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NotebooksConfig(AppConfig):
|
||||
name = 'sophon.notebooks'
|
||||
verbose_name = "Sophon Notebooks"
|
68
backend/sophon/notebooks/docker.py
Normal file
68
backend/sophon/notebooks/docker.py
Normal file
|
@ -0,0 +1,68 @@
|
|||
import enum
|
||||
import logging
|
||||
import time
|
||||
|
||||
import docker.models.containers
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
log.info("Connecting to Docker daemon...")
|
||||
client: docker.DockerClient = docker.from_env()
|
||||
log.info("Connection to Docker daemon successful!")
|
||||
|
||||
|
||||
class HealthState(enum.IntEnum):
|
||||
"""
|
||||
The health states a container can be in.
|
||||
"""
|
||||
UNDEFINED = -2
|
||||
STARTING = -1
|
||||
HEALTHY = 0
|
||||
UNHEALTHY = 1
|
||||
|
||||
|
||||
def get_health(container: docker.models.containers.Container) -> HealthState:
|
||||
"""
|
||||
Get the :class:`HealthState` of a container.
|
||||
|
||||
:param container: The container to get the health of.
|
||||
|
||||
.. seealso:: https://stackoverflow.com/a/64971593/4334568
|
||||
"""
|
||||
|
||||
log.debug(f"Getting health of {container!r}...")
|
||||
results = client.api.inspect_container(container.name)
|
||||
|
||||
if "Health" in results["State"]:
|
||||
health = results["State"]["Health"]["Status"]
|
||||
state = HealthState[health.upper()]
|
||||
else:
|
||||
state = HealthState.UNDEFINED
|
||||
|
||||
log.debug(f"{container!r} is: {state!r}")
|
||||
return state
|
||||
|
||||
|
||||
def sleep_until_container_has_started(container: docker.models.containers.Container) -> HealthState:
|
||||
"""
|
||||
Sleep until the specified container is not anymore in the ``starting`` state.
|
||||
|
||||
:param container: The container to check the health of.
|
||||
|
||||
.. seealso:: https://stackoverflow.com/a/64971593/4334568
|
||||
"""
|
||||
|
||||
log.debug(f"Blocking until {container!r} has started...")
|
||||
|
||||
while (health := get_health(container)) == HealthState.STARTING:
|
||||
# FIXME: I hope Django isn't single-threaded.
|
||||
time.sleep(0.5)
|
||||
|
||||
if health == HealthState.HEALTHY:
|
||||
log.debug(f"{container!r} has started successfully!")
|
||||
elif health == HealthState.UNDEFINED:
|
||||
log.warning(f"{container!r} does not define an healthcheck.")
|
||||
else:
|
||||
log.warning(f"{container!r} failed during startup.")
|
||||
|
||||
return health
|
8
backend/sophon/notebooks/jupyter.py
Normal file
8
backend/sophon/notebooks/jupyter.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
import secrets
|
||||
|
||||
|
||||
def generate_secure_token() -> str:
|
||||
"""
|
||||
:return: A random secure string to be used as :attr:`.container_token`.
|
||||
"""
|
||||
return secrets.token_urlsafe()
|
50
backend/sophon/notebooks/migrations/0001_initial.py
Normal file
50
backend/sophon/notebooks/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,50 @@
|
|||
# Generated by Django 3.2 on 2021-09-01 15:11
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('projects', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Notebook',
|
||||
fields=[
|
||||
('slug',
|
||||
models.SlugField(help_text='Unique alphanumeric string which identifies the project.', max_length=64, primary_key=True, serialize=False,
|
||||
verbose_name='Slug')),
|
||||
('name', models.CharField(help_text='The display name of the notebook.', max_length=512, verbose_name='Name')),
|
||||
('container_image', models.CharField(
|
||||
choices=[('jupyter/base-notebook', 'Base'), ('jupyter/minimal-notebook', 'Python'), ('jupyter/scipy-notebook', 'Python (Scientific)'),
|
||||
('jupyter/tensorflow-notebook', 'Python (Tensorflow)'), ('jupyter/r-notebook', 'Python + R'),
|
||||
('jupyter/pyspark-notebook', 'Python (Scientific) + Apache Spark'),
|
||||
('jupyter/all-spark-notebook', 'Python (Scientific) + Scala + R + Apache Spark')],
|
||||
help_text='The Docker image to run for this notebook.', max_length=256, verbose_name='Container image')),
|
||||
('container_id',
|
||||
models.CharField(help_text='The id of the Docker container running this notebook. If null, the notebook is not running.', max_length=256,
|
||||
null=True, verbose_name='Container ID')),
|
||||
('volume_id', models.CharField(
|
||||
help_text="The id of the Docker volume containing the data of this notebook. If null, the notebook doesn't currently have a volume, and will be created the next time the container is started.",
|
||||
max_length=256, null=True, verbose_name='Volume ID')),
|
||||
('port', models.IntegerField(
|
||||
help_text='The port number of the local machine at which the container is available. If null, the notebook is not running.', null=True,
|
||||
validators=[django.core.validators.MinValueValidator(49152), django.core.validators.MaxValueValidator(65535)],
|
||||
verbose_name='Local port number')),
|
||||
('locked_by', models.ForeignKey(help_text='The user who locked this notebook. If null, the notebook is unlocked.', null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
('project', models.ForeignKey(help_text='The project this notebook belongs to.', on_delete=django.db.models.deletion.CASCADE,
|
||||
to='projects.researchproject')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,43 @@
|
|||
# Generated by Django 3.2 on 2021-09-01 15:27
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('notebooks', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='notebook',
|
||||
name='container_id',
|
||||
field=models.CharField(blank=True, help_text='The id of the Docker container running this notebook. If null, the notebook is not running.',
|
||||
max_length=256, null=True, verbose_name='Container ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='notebook',
|
||||
name='locked_by',
|
||||
field=models.ForeignKey(blank=True, help_text='The user who locked this notebook. If null, the notebook is unlocked.', null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='notebook',
|
||||
name='port',
|
||||
field=models.IntegerField(blank=True,
|
||||
help_text='The port number of the local machine at which the container is available. If null, the notebook is not running.',
|
||||
null=True, validators=[django.core.validators.MinValueValidator(49152), django.core.validators.MaxValueValidator(65535)],
|
||||
verbose_name='Local port number'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='notebook',
|
||||
name='volume_id',
|
||||
field=models.CharField(blank=True,
|
||||
help_text="The id of the Docker volume containing the data of this notebook. If null, the notebook doesn't currently have a volume, and will be created the next time the container is started.",
|
||||
max_length=256, null=True, verbose_name='Volume ID'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,50 @@
|
|||
# Generated by Django 3.2 on 2021-09-05 03:48
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
import sophon.notebooks.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('notebooks', '0002_auto_20210901_1527'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='notebook',
|
||||
name='volume_id',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='notebook',
|
||||
name='jupyter_token',
|
||||
field=models.CharField(blank=True, default=sophon.notebooks.models.generate_secure_token,
|
||||
help_text='The token to allow access to the JupyterLab editor.', max_length=64, verbose_name='Jupyter Access Token'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='notebook',
|
||||
name='container_id',
|
||||
field=models.CharField(blank=True,
|
||||
help_text='The id of the Docker container running this notebook. If null, the notebook does not have an associated container.',
|
||||
max_length=256, null=True, verbose_name='Docker container ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='notebook',
|
||||
name='container_image',
|
||||
field=models.CharField(
|
||||
choices=[('jupyter/base-notebook', 'Base'), ('jupyter/minimal-notebook', 'Python'), ('jupyter/scipy-notebook', 'Python (Scientific)'),
|
||||
('jupyter/tensorflow-notebook', 'Python (Tensorflow)'), ('jupyter/r-notebook', 'Python + R'),
|
||||
('jupyter/pyspark-notebook', 'Python (Scientific) + Apache Spark'),
|
||||
('jupyter/all-spark-notebook', 'Python (Scientific) + Scala + R + Apache Spark')],
|
||||
help_text='The Docker image to run for this notebook.', max_length=256, verbose_name='Docker image'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='notebook',
|
||||
name='port',
|
||||
field=models.IntegerField(blank=True,
|
||||
help_text='The port number of the local machine at which the container is available. Can be null if the notebook is not running.',
|
||||
null=True, validators=[django.core.validators.MinValueValidator(49152), django.core.validators.MaxValueValidator(65535)],
|
||||
verbose_name='Local port number'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,33 @@
|
|||
# Generated by Django 3.2 on 2021-09-08 11:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import sophon.notebooks.jupyter
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('notebooks', '0003_auto_20210905_0348'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='notebook',
|
||||
name='internet_access',
|
||||
field=models.BooleanField(default=False,
|
||||
help_text='If true, the notebook will be able to access the Internet as the host machine. Can only be set by a superuser via the admin interface.',
|
||||
verbose_name='Allow internet access'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='notebook',
|
||||
name='jupyter_token',
|
||||
field=models.CharField(default=sophon.notebooks.jupyter.generate_secure_token, help_text='The token to allow access to the JupyterLab editor.',
|
||||
max_length=64, verbose_name='Jupyter Access Token'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='notebook',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Unique alphanumeric string which identifies the project. Changing this <strong>WILL BREAK THINGS</strong>!',
|
||||
max_length=64, primary_key=True, serialize=False, verbose_name='Slug'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 3.2 on 2021-09-08 11:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('notebooks', '0004_auto_20210908_1108'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='notebook',
|
||||
name='port',
|
||||
field=models.IntegerField(blank=True,
|
||||
help_text='The port number of the local machine at which the container is available. Can be null if the notebook is not running.',
|
||||
null=True, verbose_name='Local port number'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,26 @@
|
|||
# Generated by Django 3.2 on 2021-09-08 15:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('notebooks', '0005_alter_notebook_port'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='notebook',
|
||||
name='internet_access',
|
||||
field=models.BooleanField(default=False,
|
||||
help_text='If true, the notebook will be able to access the Internet as the host machine. Can only be set by a superuser via the admin interface. <em>Does not currently do anything.</em>',
|
||||
verbose_name='Allow internet access'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='notebook',
|
||||
name='slug',
|
||||
field=models.SlugField(
|
||||
help_text='Unique alphanumeric string which identifies the project. Changing this once the container has been created <strong>will break Docker</strong>!',
|
||||
max_length=64, primary_key=True, serialize=False, verbose_name='Slug'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 3.2 on 2021-09-08 15:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('notebooks', '0006_auto_20210908_1554'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='notebook',
|
||||
name='internet_access',
|
||||
field=models.BooleanField(default=True,
|
||||
help_text='If true, the notebook will be able to access the Internet as the host machine. Can only be set by a superuser via the admin interface. <em>Does not currently do anything.</em>',
|
||||
verbose_name='Allow internet access'),
|
||||
),
|
||||
]
|
0
backend/sophon/notebooks/migrations/__init__.py
Normal file
0
backend/sophon/notebooks/migrations/__init__.py
Normal file
545
backend/sophon/notebooks/models.py
Normal file
545
backend/sophon/notebooks/models.py
Normal file
|
@ -0,0 +1,545 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import typing as t
|
||||
|
||||
import docker.errors
|
||||
import docker.models.containers
|
||||
import docker.models.images
|
||||
import docker.models.networks
|
||||
import docker.models.volumes
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
|
||||
from sophon.core.models import SophonGroupModel, ResearchGroup
|
||||
from sophon.notebooks.apache import db as apache_db
|
||||
from sophon.notebooks.apache import get_ephemeral_port, base_domain, http_protocol
|
||||
from sophon.notebooks.docker import client as docker_client
|
||||
from sophon.notebooks.docker import sleep_until_container_has_started
|
||||
from sophon.notebooks.jupyter import generate_secure_token
|
||||
from sophon.projects.models import ResearchProject
|
||||
|
||||
module_name = __name__
|
||||
|
||||
|
||||
class Notebook(SophonGroupModel):
|
||||
"""
|
||||
A :class:`.Notebook` is a database representation of a Docker container running a JupyterLab instance.
|
||||
"""
|
||||
|
||||
class_log = logging.getLogger(f"{module_name}.{__name__}")
|
||||
|
||||
@property
|
||||
def log(self) -> logging.Logger:
|
||||
"""
|
||||
:return: A logger specific to the Notebook, allowing filtering for specific Notebooks.
|
||||
"""
|
||||
return logging.getLogger(f"{module_name}.{self.__class__.__name__}.{self.slug}")
|
||||
|
||||
slug = models.SlugField(
|
||||
"Slug",
|
||||
help_text="Unique alphanumeric string which identifies the project. Changing this once the container has been created <strong>will break Docker</strong>!",
|
||||
max_length=64,
|
||||
primary_key=True,
|
||||
)
|
||||
|
||||
project = models.ForeignKey(
|
||||
ResearchProject,
|
||||
help_text="The project this notebook belongs to.",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
name = models.CharField(
|
||||
"Name",
|
||||
help_text="The display name of the notebook.",
|
||||
max_length=512,
|
||||
)
|
||||
|
||||
locked_by = models.ForeignKey(
|
||||
User,
|
||||
help_text="The user who locked this notebook. If null, the notebook is unlocked.",
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True, null=True,
|
||||
)
|
||||
|
||||
# Remember to make a migration when changing this!
|
||||
IMAGE_CHOICES = (
|
||||
("jupyter/base-notebook", "Base"),
|
||||
("jupyter/minimal-notebook", "Python"),
|
||||
("jupyter/scipy-notebook", "Python (Scientific)"),
|
||||
("jupyter/tensorflow-notebook", "Python (Tensorflow)"),
|
||||
("jupyter/r-notebook", "Python + R"),
|
||||
("jupyter/pyspark-notebook", "Python (Scientific) + Apache Spark"),
|
||||
("jupyter/all-spark-notebook", "Python (Scientific) + Scala + R + Apache Spark"),
|
||||
)
|
||||
|
||||
container_image = models.CharField(
|
||||
"Docker image",
|
||||
help_text="The Docker image to run for this notebook.",
|
||||
choices=IMAGE_CHOICES,
|
||||
max_length=256,
|
||||
)
|
||||
|
||||
# TODO: Find a way to prevent Internet access without using the --internal flag, as it doesn't allow to expose ports
|
||||
internet_access = models.BooleanField(
|
||||
"Allow internet access",
|
||||
help_text="If true, the notebook will be able to access the Internet as the host machine. Can only be set by a superuser via the admin interface. "
|
||||
"<em>Does not currently do anything.</em>",
|
||||
default=True,
|
||||
)
|
||||
|
||||
jupyter_token = models.CharField(
|
||||
"Jupyter Access Token",
|
||||
help_text="The token to allow access to the JupyterLab editor.",
|
||||
default=generate_secure_token,
|
||||
max_length=64,
|
||||
)
|
||||
|
||||
container_id = models.CharField(
|
||||
"Docker container ID",
|
||||
help_text="The id of the Docker container running this notebook. If null, the notebook does not have an associated container.",
|
||||
blank=True, null=True,
|
||||
max_length=256,
|
||||
)
|
||||
|
||||
port = models.IntegerField(
|
||||
"Local port number",
|
||||
help_text="The port number of the local machine at which the container is available. Can be null if the notebook is not running.",
|
||||
blank=True, null=True,
|
||||
)
|
||||
|
||||
def get_group(self) -> ResearchGroup:
|
||||
return self.project.group
|
||||
|
||||
@classmethod
|
||||
def get_fields(cls) -> set[str]:
|
||||
return {
|
||||
"slug",
|
||||
"project",
|
||||
"name",
|
||||
"locked_by",
|
||||
"container_image",
|
||||
"internet_access",
|
||||
"jupyter_token",
|
||||
"is_running",
|
||||
"lab_url",
|
||||
"legacy_notebook_url",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_editable_fields(cls) -> set[str]:
|
||||
return {
|
||||
"name",
|
||||
"locked_by",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_administrable_fields(cls) -> set[str]:
|
||||
return {
|
||||
"project",
|
||||
"container_image",
|
||||
"jupyter_token",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_creation_fields(cls) -> set[str]:
|
||||
return {
|
||||
"slug",
|
||||
"project",
|
||||
"name",
|
||||
"container_image",
|
||||
"jupyter_token",
|
||||
}
|
||||
|
||||
@property
|
||||
def container_name(self) -> str:
|
||||
"""
|
||||
:return: The name given to the container associated with this :class:`Notebook`.
|
||||
"""
|
||||
return f"{os.environ.get('SOPHON_CONTAINER_PREFIX', 'sophon-container')}-{self.slug}"
|
||||
|
||||
@property
|
||||
def volume_name(self) -> str:
|
||||
"""
|
||||
:return: The name given to the volume associated with this :class:`Notebook`.
|
||||
"""
|
||||
return f"{os.environ.get('SOPHON_VOLUME_PREFIX', 'sophon-volume')}-{self.slug}"
|
||||
|
||||
@property
|
||||
def network_name(self) -> str:
|
||||
"""
|
||||
:return: The name given to the network associated with this :class:`Notebook`.
|
||||
"""
|
||||
return f"{os.environ.get('SOPHON_NETWORK_PREFIX', 'sophon-network')}-{self.slug}"
|
||||
|
||||
@property
|
||||
def external_domain(self) -> str:
|
||||
"""
|
||||
:return: The domain name where this :class:`Notebook` will be accessible on the Internet after its container is started.
|
||||
"""
|
||||
return f"{self.slug}.{base_domain}"
|
||||
|
||||
@property
|
||||
def lab_url(self) -> t.Optional[str]:
|
||||
"""
|
||||
:return: The URL where the JupyterLab instance can be accessed.
|
||||
|
||||
.. warning:: Anyone with this URL will have edit access to the Jupyter instance!
|
||||
"""
|
||||
if not self.is_running:
|
||||
return None
|
||||
return f"{http_protocol}://{self.external_domain}/lab?token={self.jupyter_token}"
|
||||
|
||||
@property
|
||||
def legacy_notebook_url(self) -> t.Optional[str]:
|
||||
"""
|
||||
:return: The URL where the legacy Jupyter Notebook instance can be accessed.
|
||||
|
||||
.. warning:: Anyone with this URL will have edit access to the Jupyter instance!
|
||||
"""
|
||||
if not self.is_running:
|
||||
return None
|
||||
return f"{http_protocol}://{self.external_domain}/tree?token={self.jupyter_token}"
|
||||
|
||||
@property
|
||||
def internal_domain(self) -> t.Optional[str]:
|
||||
"""
|
||||
:return: The domain name where this :class:`Notebook` is accessible on the local machine, or :data:`None` if no port has been assigned to this
|
||||
container yet.
|
||||
"""
|
||||
if self.port is None:
|
||||
return None
|
||||
return f"localhost:{self.port}"
|
||||
|
||||
def regenerate_secure_token(self):
|
||||
"""
|
||||
Replace the current :attr:`.container_token` with a different one.
|
||||
|
||||
.. warning:: Only effective after a container restart!
|
||||
"""
|
||||
|
||||
self.container_token = generate_secure_token()
|
||||
self.save()
|
||||
|
||||
def get_volume(self) -> t.Optional[docker.models.volumes.Volume]:
|
||||
"""
|
||||
Get the :class:`~docker.models.volumes.Volume` associated with this :class:`Notebook`.
|
||||
|
||||
:return: The retrieved :class:`~docker.models.volumes.Volume`, or :data:`None` if the volume does not exist.
|
||||
:raises docker.errors.APIError: If something goes wrong in the volume retrieval.
|
||||
"""
|
||||
|
||||
self.log.debug(f"Getting volume {self.volume_name!r}...")
|
||||
try:
|
||||
return docker_client.volumes.get(self.volume_name)
|
||||
except docker.errors.NotFound:
|
||||
return None
|
||||
|
||||
def make_volume(self) -> docker.models.volumes.Volume:
|
||||
"""
|
||||
Get the :class:`~docker.models.volumes.Volume` associated with this :class:`Notebook`, or **create it** if it doesn't exist.
|
||||
|
||||
:return: The resulting :class:`~docker.models.volumes.Volume`.
|
||||
:raises docker.errors.APIError: If something goes wrong in the volume retrieval or creation.
|
||||
"""
|
||||
|
||||
self.log.debug(f"Making volume {self.volume_name!r}...")
|
||||
|
||||
if volume := self.get_volume():
|
||||
self.log.debug(f"Volume {self.volume_name!r} exists: {volume!r}")
|
||||
return volume
|
||||
|
||||
self.log.debug(f"Volume does not exist, creating it now...")
|
||||
volume = docker_client.volumes.create(
|
||||
name=self.volume_name,
|
||||
)
|
||||
self.log.info(f"Created {volume!r}")
|
||||
return volume
|
||||
|
||||
def get_network(self) -> t.Optional[docker.models.networks.Network]:
|
||||
"""
|
||||
Get the :class:`~docker.models.networks.Network` associated with this :class:`Notebook`.
|
||||
|
||||
:return: The retrieved :class:`~docker.models.networks.Network`, or :data:`None` if the network does not exist.
|
||||
:raises docker.errors.APIError: If something goes wrong in the network retrieval.
|
||||
"""
|
||||
|
||||
self.log.debug(f"Getting network {self.network_name!r}...")
|
||||
try:
|
||||
return docker_client.networks.get(self.network_name)
|
||||
except docker.errors.NotFound:
|
||||
return None
|
||||
|
||||
def make_network(self) -> docker.models.networks.Network:
|
||||
"""
|
||||
Get the :class:`~docker.models.networks.Network` associated with this :class:`Notebook`, or **create it** if it doesn't exist.
|
||||
|
||||
:return: The resulting :class:`~docker.models.networks.Network`.
|
||||
:raises docker.errors.APIError: If something goes wrong in the network retrieval or creation.
|
||||
"""
|
||||
|
||||
self.log.debug(f"Making network {self.network_name!r}...")
|
||||
|
||||
if network := self.get_network():
|
||||
self.log.debug(f"Network {self.network_name!r} exists: {network!r}")
|
||||
return network
|
||||
|
||||
self.log.debug(f"Network does not exist, creating it now...")
|
||||
network = docker_client.networks.create(
|
||||
name=self.network_name,
|
||||
internal=not self.internet_access,
|
||||
)
|
||||
self.log.info(f"Created {network!r}")
|
||||
return network
|
||||
|
||||
def disable_proxying(self) -> None:
|
||||
"""
|
||||
Disable the proxying of this :class:`Notebook` by removing its URLs from the :data:`apache_db` and its port from :attr:`.port`.
|
||||
"""
|
||||
|
||||
self.log.debug("Unassigning port...")
|
||||
self.port = None
|
||||
|
||||
self.log.debug("Removing entry from the apache_db...")
|
||||
try:
|
||||
del apache_db[self.external_domain]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
self.log.debug("Clearing port from the SQL database...")
|
||||
self.save()
|
||||
|
||||
def enable_proxying(self) -> None:
|
||||
"""
|
||||
Enable the proxying of this :class:`Notebook` by adding its URLs to the :data:`apache_db` and its port to :attr:`.port`.
|
||||
"""
|
||||
|
||||
self.log.debug("Getting free port...")
|
||||
self.port: int = get_ephemeral_port()
|
||||
|
||||
self.log.debug("Adding entry to the apache_db...")
|
||||
apache_db[self.external_domain] = f"{self.internal_domain}"
|
||||
|
||||
self.log.debug("Saving port to the SQL database...")
|
||||
self.save()
|
||||
|
||||
def get_container(self) -> t.Optional[docker.models.containers.Container]:
|
||||
"""
|
||||
Get the :class:`~docker.models.containers.Container` associated with this :class:`Notebook`.
|
||||
|
||||
:return: The retrieved :class:`~docker.models.containers.Container`, or :data:`None` if no container is associated with this :class:`Notebook` or the associated container does not exist anymore.
|
||||
:raises docker.errors.NotFound: If no container was found with the id :attr:`.container_id`.
|
||||
"""
|
||||
|
||||
if self.container_id is None:
|
||||
return None
|
||||
return docker_client.containers.get(self.container_id)
|
||||
|
||||
def sync_container(self) -> t.Optional[docker.models.containers.Container]:
|
||||
"""
|
||||
Tries to get the :class:`~docker.models.containers.Container` associated with this :class:`Notebook` directly from Docker using its expected name:
|
||||
- if it returns a **running :class:`~docker.models.containers.Container`**, it sets the :attr:`.container_id` field and returns it;
|
||||
- if it returns a **exited :class:`~docker.models.containers.Container`**, it removes it and returns :data:`None`;
|
||||
- if it returns :data:`None`, it returns :data:`None`;
|
||||
- if it raises :exc:`docker.errors.NotFound`, it clears the :attr:`.container_id` field and returns :data:`None`.
|
||||
|
||||
:return: Either a :class:`docker.models.containers.Container` or :data:`None`.
|
||||
"""
|
||||
try:
|
||||
container = docker_client.containers.get(self.container_name)
|
||||
|
||||
except docker.errors.NotFound:
|
||||
try:
|
||||
return self.remove_container()
|
||||
except self.ContainerError:
|
||||
return
|
||||
|
||||
if container is None:
|
||||
return None
|
||||
|
||||
if container.status == "exited":
|
||||
try:
|
||||
return self.remove_container()
|
||||
except self.ContainerError:
|
||||
return
|
||||
|
||||
self.container_id = container.id
|
||||
# self.port = container.ports
|
||||
return container
|
||||
|
||||
class ContainerError(Exception):
|
||||
"""
|
||||
An error related to the :class:`~docker.models.containers.Container` associated with this :class:`Notebook`.
|
||||
"""
|
||||
|
||||
def remove_container(self) -> None:
|
||||
"""
|
||||
Remove the :class:`~docker.models.containers.Container` associated with this :class:`Notebook`.
|
||||
|
||||
:raises .ContainerError: If the :class:`Notebook` has no associated :class:`~docker.models.containers.Container`.
|
||||
"""
|
||||
|
||||
container = self.get_container()
|
||||
if container is None:
|
||||
raise self.ContainerError("Notebook has no associated Container")
|
||||
|
||||
self.log.debug("Disabling proxying...")
|
||||
self.disable_proxying()
|
||||
|
||||
self.log.debug("Stopping container...")
|
||||
container.stop()
|
||||
|
||||
self.log.debug("Removing container...")
|
||||
container.remove()
|
||||
self.container_id = None
|
||||
|
||||
self.log.debug("Clearing container_id in the SQL database...")
|
||||
self.save()
|
||||
|
||||
@classmethod
|
||||
def pull_images(cls):
|
||||
"""
|
||||
Ask the Docker daemon to pull the images defined in :attr:`.IMAGE_CHOICES`, so that there is no download delay when starting a container for a
|
||||
non-pulled image.
|
||||
"""
|
||||
cls.class_log.debug("Pulling images in the available choices...")
|
||||
for image, _ in cls.IMAGE_CHOICES:
|
||||
cls.class_log.info(f"Pulling {image!r}...")
|
||||
docker_client.images.pull(image)
|
||||
|
||||
def create_container(self) -> docker.models.containers.Container:
|
||||
"""
|
||||
Create a :class:`~docker.models.containers.Container` and associate it with this :class:`Notebook`.
|
||||
|
||||
:return: The created :class:`~docker.models.containers.Container`.
|
||||
:raises .ContainerError: If the :class:`Notebook` has already an associated :class:`~docker.models.containers.Container`.
|
||||
"""
|
||||
if self.get_container():
|
||||
raise self.ContainerError("Notebook has already an associated Container")
|
||||
|
||||
self.log.debug("Ensuring the container's volume exists...")
|
||||
volume = self.make_volume()
|
||||
|
||||
self.log.debug("Ensuring the container's network exists...")
|
||||
network = self.make_network()
|
||||
|
||||
self.log.debug("Enabling proxying...")
|
||||
self.enable_proxying()
|
||||
|
||||
self.log.debug("Checking if the image is available...")
|
||||
try:
|
||||
docker_client.images.get(self.container_image)
|
||||
except docker.errors.ImageNotFound:
|
||||
self.log.warning("Image has not been pulled, creating the container will take a long time!")
|
||||
|
||||
self.log.debug("Creating container...")
|
||||
container: docker.models.containers.Container = docker_client.containers.run(
|
||||
detach=True,
|
||||
image=self.container_image,
|
||||
name=self.container_name,
|
||||
ports={
|
||||
"8888/tcp": (f"127.0.0.1", f"{self.port}/tcp")
|
||||
},
|
||||
environment={
|
||||
"JUPYTER_ENABLE_LAB": "yes",
|
||||
"RESTARTABLE": "yes",
|
||||
"GRANT_SUDO": "yes",
|
||||
"JUPYTER_TOKEN": self.jupyter_token,
|
||||
},
|
||||
volumes={
|
||||
volume.name: {
|
||||
"bind": "/home/jovyan",
|
||||
"mode": "rw",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
self.container_id = container.id
|
||||
return container
|
||||
|
||||
def stop_container(self) -> None:
|
||||
"""
|
||||
Like :meth:`.remove_container`, but will sync the notebook state with Apache and the Docker daemon and won't raise an error if the
|
||||
:class:`~docker.models.containers.Container` already exists, instead not doing anything.
|
||||
"""
|
||||
|
||||
self.sync_container()
|
||||
try:
|
||||
self.remove_container()
|
||||
except self.ContainerError:
|
||||
pass
|
||||
|
||||
def start_container(self) -> docker.models.containers.Container:
|
||||
"""
|
||||
Like :meth:`.create_container`, but will sync the notebook state with Apache and the Docker daemon and won't raise an error if the
|
||||
:class:`~docker.models.containers.Container` already exists, instead returning the already existing object.
|
||||
"""
|
||||
|
||||
self.sync_container()
|
||||
try:
|
||||
return self.create_container()
|
||||
except self.ContainerError:
|
||||
return self.get_container()
|
||||
|
||||
def sleep_until_container_has_started(self) -> None:
|
||||
"""
|
||||
Calls :func:`sleep_until_container_has_started` on the associated container.
|
||||
|
||||
:raises .ContainerError: If the :class:`Notebook` has no associated :class:`~docker.models.containers.Container`.
|
||||
"""
|
||||
|
||||
container = self.get_container()
|
||||
|
||||
self.log.debug("Sleeping until the Container is healthy...")
|
||||
sleep_until_container_has_started(container)
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
Create and start everything required for the :class:`Notebook` to work, blocking until it's all ready.
|
||||
"""
|
||||
|
||||
self.log.info("Starting Notebook...")
|
||||
self.start_container()
|
||||
self.sleep_until_container_has_started()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""
|
||||
Stop and destroy everything used by the :class:`Notebook`, blocking until it's all torn down.
|
||||
"""
|
||||
|
||||
self.log.info("Stopping Notebook...")
|
||||
self.stop_container()
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""
|
||||
:return: :data:`True` if the :class:`Notebook` is :meth:`.start`\\ ed and ready, :data:`False` otherwise.
|
||||
"""
|
||||
try:
|
||||
container = self.get_container()
|
||||
|
||||
except docker.errors.NotFound:
|
||||
return False
|
||||
|
||||
if container is None:
|
||||
return False
|
||||
|
||||
if container.status == "exited":
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def sync_running(self) -> bool:
|
||||
"""
|
||||
:return: :data:`True` if the :class:`Notebook` is :meth:`.start`\\ ed and ready, :data:`False` otherwise.
|
||||
|
||||
.. warning:: As a side effect, this function calls :meth:`.sync_container`, updating the object's state. Therefore, it should not be used in attributes
|
||||
reachable by GET requests.
|
||||
"""
|
||||
|
||||
container = self.sync_container()
|
||||
return container is not None
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
1
backend/sophon/notebooks/tests.py
Normal file
1
backend/sophon/notebooks/tests.py
Normal file
|
@ -0,0 +1 @@
|
|||
# Create your tests here.
|
16
backend/sophon/notebooks/urls.py
Normal file
16
backend/sophon/notebooks/urls.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
import rest_framework.routers
|
||||
from django.urls import path, include
|
||||
|
||||
from . import views
|
||||
|
||||
project_router = rest_framework.routers.DefaultRouter()
|
||||
project_router.register("", views.NotebooksByProjectViewSet, basename="notebook-by-project")
|
||||
|
||||
slug_router = rest_framework.routers.DefaultRouter()
|
||||
slug_router.register("", views.NotebooksBySlugViewSet, basename="notebook-by-slug")
|
||||
|
||||
urlpatterns = [
|
||||
# It would be nice for this to be documented...
|
||||
path("by-project/<slug:project_slug>/", include(project_router.urls)),
|
||||
path("by-slug/", include(slug_router.urls)),
|
||||
]
|
98
backend/sophon/notebooks/views.py
Normal file
98
backend/sophon/notebooks/views.py
Normal file
|
@ -0,0 +1,98 @@
|
|||
import abc
|
||||
import typing as t
|
||||
|
||||
from django.db.models import Q
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from sophon.core.models import ResearchGroup
|
||||
from sophon.core.views import SophonGroupViewSet
|
||||
from sophon.notebooks.models import Notebook
|
||||
from sophon.projects.models import ResearchProject
|
||||
|
||||
|
||||
class NotebooksViewSet(SophonGroupViewSet, metaclass=abc.ABCMeta):
|
||||
def get_group_from_serializer(self, serializer) -> ResearchGroup:
|
||||
return serializer.validated_data["project"].group
|
||||
|
||||
@action(["PATCH"], detail=True)
|
||||
def sync(self, request: Request, **kwargs):
|
||||
"""
|
||||
Update the `Notebook`'s state.
|
||||
"""
|
||||
notebook: Notebook = self.get_object()
|
||||
notebook.sync_container()
|
||||
Serializer = notebook.get_access_serializer(request.user)
|
||||
serializer = Serializer(notebook)
|
||||
return Response(serializer.data, status.HTTP_200_OK)
|
||||
|
||||
@action(["PATCH"], detail=True)
|
||||
def start(self, request: Request, **kwargs):
|
||||
"""
|
||||
Start the `Notebook`.
|
||||
"""
|
||||
notebook: Notebook = self.get_object()
|
||||
notebook.start()
|
||||
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):
|
||||
"""
|
||||
Stop the `Notebook`.
|
||||
"""
|
||||
notebook: Notebook = self.get_object()
|
||||
notebook.stop()
|
||||
Serializer = notebook.get_access_serializer(request.user)
|
||||
serializer = Serializer(notebook)
|
||||
return Response(serializer.data, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class NotebooksByProjectViewSet(NotebooksViewSet):
|
||||
"""
|
||||
Access `Notebook`s filtered by `ResearchProject`.
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_anonymous:
|
||||
return Notebook.objects.filter(
|
||||
Q(project__slug=self.kwargs["project_slug"]) &
|
||||
Q(project__visibility="PUBLIC")
|
||||
)
|
||||
else:
|
||||
return Notebook.objects.filter(
|
||||
Q(project__slug=self.kwargs["project_slug"]) & (
|
||||
Q(project__visibility="PUBLIC") |
|
||||
Q(project__visibility="INTERNAL") |
|
||||
Q(project__visibility="PRIVATE", project__group__members__in=[self.request.user])
|
||||
)
|
||||
)
|
||||
|
||||
def hook_create(self, serializer) -> dict[str, t.Any]:
|
||||
result = super().hook_create(serializer)
|
||||
project = ResearchProject.objects.filter(pk=self.kwargs["project_slug"]).get()
|
||||
return {
|
||||
**result,
|
||||
"project": project,
|
||||
}
|
||||
|
||||
|
||||
class NotebooksBySlugViewSet(NotebooksViewSet):
|
||||
"""
|
||||
Access `Notebook`s directly by their `slug`s.
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_anonymous:
|
||||
return Notebook.objects.filter(
|
||||
Q(project__visibility="PUBLIC")
|
||||
)
|
||||
else:
|
||||
return Notebook.objects.filter(
|
||||
Q(project__visibility="PUBLIC") |
|
||||
Q(project__visibility="INTERNAL") |
|
||||
Q(project__visibility="PRIVATE", project__group__members__in=[self.request.user])
|
||||
)
|
|
@ -0,0 +1,7 @@
|
|||
"""
|
||||
The :mod:`sophon.projects` module allows groups to create Research Projects with varying visibility levels.
|
||||
|
||||
It depends on the following :mod:`django` apps:
|
||||
|
||||
- `sophon.core`
|
||||
"""
|
|
@ -5,7 +5,7 @@ from . import views
|
|||
|
||||
|
||||
router = rest_framework.routers.DefaultRouter()
|
||||
router.register("projects", views.ResearchProjectViewSet, basename="research-project")
|
||||
router.register("", views.ResearchProjectViewSet, basename="research-project")
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
|
|
|
@ -46,6 +46,7 @@ INSTALLED_APPS = [
|
|||
'colorfield',
|
||||
'sophon.core',
|
||||
'sophon.projects',
|
||||
'sophon.notebooks',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
@ -168,11 +169,18 @@ REST_FRAMEWORK = {
|
|||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"formatters": {
|
||||
"detail": {
|
||||
"format": "{asctime}\t| {levelname}\t| {name}\t| {message}",
|
||||
"style": "{",
|
||||
}
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"level": "DEBUG",
|
||||
"stream": "ext://sys.stdout",
|
||||
"formatter": "detail",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
|
|
|
@ -23,4 +23,5 @@ urlpatterns = [
|
|||
path('api/auth/session/', include("rest_framework.urls")),
|
||||
path('api/core/', include("sophon.core.urls")),
|
||||
path('api/projects/', include("sophon.projects.urls")),
|
||||
path('api/notebooks/', include("sophon.notebooks.urls")),
|
||||
]
|
||||
|
|
|
@ -2,7 +2,9 @@
|
|||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Poetry (frontend)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
|
|
|
@ -1,46 +0,0 @@
|
|||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `yarn start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.\
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `yarn test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `yarn build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `yarn eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
|
@ -3,6 +3,7 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@steffo/bluelib-react": "^3.0.7",
|
||||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/react": "^11.1.0",
|
||||
"@testing-library/user-event": "^12.1.10",
|
||||
|
@ -10,6 +11,7 @@
|
|||
"@types/node": "^12.0.0",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"axios": "^0.21.1",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-scripts": "4.0.3",
|
||||
|
|
|
@ -1,43 +1,22 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport"/>
|
||||
<meta content="#01051B" name="theme-color"/>
|
||||
<meta content="Research hub for universities" name="description"/>
|
||||
<!--suppress HtmlUnknownTarget -->
|
||||
<link href="%PUBLIC_URL%/favicon.ico" rel="icon"/>
|
||||
<!--suppress HtmlUnknownTarget -->
|
||||
<link href="%PUBLIC_URL%/logo192.png" rel="apple-touch-icon"/>
|
||||
<!--suppress HtmlUnknownTarget -->
|
||||
<link href="%PUBLIC_URL%/manifest.json" rel="manifest"/>
|
||||
<title>Sophon</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>Sophon requires Javascript, which is not currently enabled on this browser.</noscript>
|
||||
<div id="react">
|
||||
<!-- React components are rendered here -->
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -1,25 +1,25 @@
|
|||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#01051B",
|
||||
"background_color": "#000014"
|
||||
}
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import {render, screen} from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
render(<App/>);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
|
|
|
@ -1,26 +1,21 @@
|
|||
import React from 'react';
|
||||
import logo from './logo.svg';
|
||||
import './App.css';
|
||||
import * as React from 'react';
|
||||
import {Bluelib, Box, Heading, LayoutThreeCol} from "@steffo/bluelib-react";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
<img src={logo} className="App-logo" alt="logo" />
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to reload.
|
||||
</p>
|
||||
<a
|
||||
className="App-link"
|
||||
href="https://reactjs.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn React
|
||||
</a>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Bluelib theme={"sophon"}>
|
||||
<LayoutThreeCol>
|
||||
<LayoutThreeCol.Center>
|
||||
<Heading level={1}>
|
||||
Sophon
|
||||
</Heading>
|
||||
<Box>
|
||||
Welcome to Sophon!
|
||||
</Box>
|
||||
</LayoutThreeCol.Center>
|
||||
</LayoutThreeCol>
|
||||
</Bluelib>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
72
frontend/src/hooks/useStorageState.ts
Normal file
72
frontend/src/hooks/useStorageState.ts
Normal file
|
@ -0,0 +1,72 @@
|
|||
import { useCallback, useState } from "react"
|
||||
|
||||
|
||||
/**
|
||||
* Hook with the same API as {@link React.useState} which additionally stores its value in a {@link Storage}.
|
||||
*/
|
||||
export function useStorageState<T>(storage: Storage, key: string, def: T) {
|
||||
/**
|
||||
* Load the `key` from the `storage` into `value`, defaulting to `def` if it is not found.
|
||||
*/
|
||||
const load = useCallback(
|
||||
() => {
|
||||
if(storage) {
|
||||
console.debug(`Loading ${key} from ${storage}...`)
|
||||
const stored = storage.getItem(key)
|
||||
|
||||
if(!stored) {
|
||||
console.debug(`There is no value ${key} stored, defaulting...`)
|
||||
return def
|
||||
}
|
||||
|
||||
let parsed = JSON.parse(stored)
|
||||
|
||||
if(parsed) {
|
||||
console.debug(`Loaded ${key} from storage!`)
|
||||
return parsed
|
||||
}
|
||||
else {
|
||||
console.debug(`Could not parse stored value at ${key}, defaulting...`)
|
||||
return def
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.warn(`Can't load ${key} as ${storage} doesn't seem to be available, defaulting...`)
|
||||
return def
|
||||
}
|
||||
},
|
||||
[storage, key, def]
|
||||
)
|
||||
|
||||
/**
|
||||
* Save a value to the `storage`.
|
||||
*/
|
||||
const save = useCallback(
|
||||
val => {
|
||||
if(storage) {
|
||||
console.debug(`Saving ${key} to storage...`)
|
||||
storage.setItem(key, JSON.stringify(val))
|
||||
console.debug(`Saved ${val} at ${key} in storage!`)
|
||||
}
|
||||
else {
|
||||
console.warn(`Can't save ${key} as storage doesn't seem to be available...`)
|
||||
}
|
||||
},
|
||||
[storage, key],
|
||||
)
|
||||
|
||||
const [value, setValue] = useState(load())
|
||||
|
||||
/**
|
||||
* Set `value` and save it to the {@link storage}.
|
||||
*/
|
||||
const setAndSaveValue = useCallback(
|
||||
val => {
|
||||
setValue(val)
|
||||
save(val)
|
||||
},
|
||||
[setValue, save],
|
||||
)
|
||||
|
||||
return [value, setAndSaveValue]
|
||||
}
|
|
@ -1,13 +1,13 @@
|
|||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background-color: #000014;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
body > * {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body > * > * {
|
||||
min-height: 100vh;
|
||||
}
|
|
@ -5,10 +5,10 @@ import App from './App';
|
|||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
<React.StrictMode>
|
||||
<App/>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('react')
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
Before Width: | Height: | Size: 2.6 KiB |
|
@ -1,15 +1,15 @@
|
|||
import { ReportHandler } from 'web-vitals';
|
||||
import {ReportHandler} from 'web-vitals';
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({getCLS, getFID, getFCP, getLCP, getTTFB}) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
|
|
|
@ -2,4 +2,4 @@
|
|||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
import '@testing-library/jest-dom';
|
|
@ -1,26 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -1098,7 +1098,7 @@
|
|||
dependencies:
|
||||
regenerator-runtime "^0.13.4"
|
||||
|
||||
"@babel/runtime@^7.12.5", "@babel/runtime@^7.9.2":
|
||||
"@babel/runtime@^7.12.5", "@babel/runtime@^7.15.3", "@babel/runtime@^7.9.2":
|
||||
version "7.15.3"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.15.3.tgz#2e1c2880ca118e5b2f9988322bd8a7656a32502b"
|
||||
integrity sha512-OvwMLqNXkCXSz1kSm58sEsNuhqOx/fKpnUnKnFB5v8uDda5bLNEHNgKPvhDN6IU0LDcnHQ90LlJ0Q6jnyBSIBA==
|
||||
|
@ -1490,6 +1490,16 @@
|
|||
dependencies:
|
||||
"@sinonjs/commons" "^1.7.0"
|
||||
|
||||
"@steffo/bluelib-react@^3.0.7":
|
||||
version "3.0.7"
|
||||
resolved "https://registry.yarnpkg.com/@steffo/bluelib-react/-/bluelib-react-3.0.7.tgz#ba6632bdbe784472eb79d95194e36b840fa68cae"
|
||||
integrity sha512-SAY/bs9sVAqQj1rg4F91xOuZt/ie3tTwf7J6cSej53LLHmU3n4Pt84KaUaehuh2U5u4m4bm5A5A1EqwLXemh1w==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.15.3"
|
||||
classnames "^2.3.1"
|
||||
color "https://github.com/Steffo99/color"
|
||||
uuid "^8.3.2"
|
||||
|
||||
"@surma/rollup-plugin-off-main-thread@^1.1.1":
|
||||
version "1.4.2"
|
||||
resolved "https://registry.yarnpkg.com/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-1.4.2.tgz#e6786b6af5799f82f7ab3a82e53f6182d2b91a58"
|
||||
|
@ -2556,6 +2566,13 @@ axe-core@^4.0.2:
|
|||
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.1.2.tgz#7cf783331320098bfbef620df3b3c770147bc224"
|
||||
integrity sha512-V+Nq70NxKhYt89ArVcaNL9FDryB3vQOd+BFXZIfO3RP6rwtj+2yqqqdHEkacutglPaZLkJeuXKCjCJDMGPtPqg==
|
||||
|
||||
axios@^0.21.1:
|
||||
version "0.21.1"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8"
|
||||
integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==
|
||||
dependencies:
|
||||
follow-redirects "^1.10.0"
|
||||
|
||||
axobject-query@^2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be"
|
||||
|
@ -3277,6 +3294,11 @@ class-utils@^0.3.5:
|
|||
isobject "^3.0.0"
|
||||
static-extend "^0.1.1"
|
||||
|
||||
classnames@^2.3.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e"
|
||||
integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==
|
||||
|
||||
clean-css@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78"
|
||||
|
@ -3366,6 +3388,14 @@ color-string@^1.5.4:
|
|||
color-name "^1.0.0"
|
||||
simple-swizzle "^0.2.2"
|
||||
|
||||
color-string@^1.6.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.6.0.tgz#c3915f61fe267672cb7e1e064c9d692219f6c312"
|
||||
integrity sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==
|
||||
dependencies:
|
||||
color-name "^1.0.0"
|
||||
simple-swizzle "^0.2.2"
|
||||
|
||||
color@^3.0.0:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/color/-/color-3.1.3.tgz#ca67fb4e7b97d611dcde39eceed422067d91596e"
|
||||
|
@ -3374,6 +3404,13 @@ color@^3.0.0:
|
|||
color-convert "^1.9.1"
|
||||
color-string "^1.5.4"
|
||||
|
||||
"color@https://github.com/Steffo99/color":
|
||||
version "4.0.1"
|
||||
resolved "https://github.com/Steffo99/color#2f9e457a96a5ee694d4bc6a2f4bc544ff32426e1"
|
||||
dependencies:
|
||||
color-convert "^2.0.1"
|
||||
color-string "^1.6.0"
|
||||
|
||||
colorette@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.1.tgz#4d0b921325c14faf92633086a536db6e89564b1b"
|
||||
|
@ -5071,6 +5108,11 @@ follow-redirects@^1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.2.tgz#dd73c8effc12728ba5cf4259d760ea5fb83e3147"
|
||||
integrity sha512-6mPTgLxYm3r6Bkkg0vNM0HTjfGrOEtsfbhagQvbxDEsEkpNhw582upBaoRZylzen6krEmxXJgt9Ju6HiI4O7BA==
|
||||
|
||||
follow-redirects@^1.10.0:
|
||||
version "1.14.3"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.3.tgz#6ada78118d8d24caee595595accdc0ac6abd022e"
|
||||
integrity sha512-3MkHxknWMUtb23apkgz/83fDoe+y+qr0TdgacGIA7bew+QLBo3vdgEN2xEsuXNivpFy4CyDhBBZnNZOtalmenw==
|
||||
|
||||
for-in@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
|
||||
|
@ -10926,7 +10968,7 @@ uuid@^3.3.2, uuid@^3.4.0:
|
|||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
|
||||
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
|
||||
|
||||
uuid@^8.3.0:
|
||||
uuid@^8.3.0, uuid@^8.3.2:
|
||||
version "8.3.2"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
|
||||
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
|
||||
|
|
Loading…
Reference in a new issue