1
Fork 0
mirror of https://github.com/Steffo99/sophon.git synced 2024-12-21 22:34:21 +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:
Steffo 2021-09-08 18:05:01 +02:00 committed by GitHub
parent e335cea93a
commit d06959dc06
48 changed files with 1474 additions and 231 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
proxy.dbm

View file

@ -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" />

View file

@ -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">

View file

@ -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$" />

View file

@ -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
View file

@ -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"},
]

View file

@ -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]

View file

@ -0,0 +1,3 @@
"""
The :mod:`sophon.core` module provides the base Sophon functionality, such as users and research groups.
"""

View file

@ -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):

View 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.
"""

View 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",
),
},
),
)

View 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]

View 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

View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class NotebooksConfig(AppConfig):
name = 'sophon.notebooks'
verbose_name = "Sophon Notebooks"

View 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

View 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()

View 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,
},
),
]

View file

@ -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'),
),
]

View file

@ -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'),
),
]

View file

@ -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'),
),
]

View file

@ -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'),
),
]

View file

@ -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'),
),
]

View file

@ -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'),
),
]

View 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

View file

@ -0,0 +1 @@
# Create your tests here.

View 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)),
]

View 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])
)

View file

@ -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`
"""

View file

@ -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 = [

View file

@ -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": {

View file

@ -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")),
]

View file

@ -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>

View file

@ -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 cant go back!**
If you arent 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 youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt 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/).

View file

@ -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",

View file

@ -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>

View file

@ -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"
}

View file

@ -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);
}
}

View file

@ -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();
});

View file

@ -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;

View 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]
}

View file

@ -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;
}

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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';

View file

@ -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"
]
}

View file

@ -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==