Unable to interrupt a running server...
I'm quitting, waiting for the upstream issue to be resolved.
This commit is contained in:
parent
0565781c15
commit
556f0bf8f3
6
Makefile
6
Makefile
@ -6,11 +6,11 @@ BLACK_OPTS = --exclude templates ${SRC_DIRS}
|
||||
runserver: ## Run a development server
|
||||
tutor dash run --dev
|
||||
|
||||
scss: ## Compile SCSS files
|
||||
css: ## Compile SCSS files to CSS
|
||||
sass ${SASS_OPTS} tutordash/server/static/scss/:tutordash/server/static/css/
|
||||
|
||||
scss-watch: ## Compile SCSS files and watch for changes
|
||||
$(MAKE) scss SASS_OPTS="--watch"
|
||||
css-watch: ## Compile SCSS files to CSS and watch for changes
|
||||
$(MAKE) css SASS_OPTS="--watch"
|
||||
|
||||
# Warning: These checks are not necessarily run on every PR.
|
||||
test: test-lint test-types test-format # Run some static checks.
|
||||
|
||||
@ -11,6 +11,8 @@ Installation
|
||||
|
||||
pip install git+https://github.com/overhangio/tutor-dash
|
||||
|
||||
.. TODO how to package css files?
|
||||
|
||||
Usage
|
||||
*****
|
||||
|
||||
|
||||
10
setup.py
10
setup.py
@ -6,13 +6,13 @@ from setuptools import find_packages, setup
|
||||
HERE = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
|
||||
def load_readme():
|
||||
def load_readme() -> str:
|
||||
with io.open(os.path.join(HERE, "README.rst"), "rt", encoding="utf8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def load_about():
|
||||
about = {}
|
||||
def load_about() -> dict[str, str]:
|
||||
about: dict[str, str] = {}
|
||||
with io.open(
|
||||
os.path.join(HERE, "tutordash", "__about__.py"),
|
||||
"rt",
|
||||
@ -43,13 +43,13 @@ setup(
|
||||
include_package_data=True,
|
||||
python_requires=">=3.9",
|
||||
install_requires=[
|
||||
"tutor>=18.0.0,<19.0.0",
|
||||
"tutor>=19.0.0,<20.0.0",
|
||||
"quart",
|
||||
"aiofiles",
|
||||
],
|
||||
extras_require={
|
||||
"dev": [
|
||||
"tutor[dev]>=18.0.0,<19.0.0",
|
||||
"tutor[dev]>=19.0.0,<20.0.0",
|
||||
"quart",
|
||||
"aiofiles",
|
||||
"types-aiofiles",
|
||||
|
||||
@ -1 +1 @@
|
||||
__version__ = "18.0.0"
|
||||
__version__ = "19.0.0"
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
@ -16,6 +17,7 @@ from quart import (
|
||||
url_for,
|
||||
)
|
||||
from quart.helpers import WerkzeugResponse
|
||||
from quart.typing import ResponseTypes
|
||||
|
||||
import tutor.env
|
||||
from tutor.exceptions import TutorError
|
||||
@ -280,6 +282,7 @@ app = Quart(
|
||||
static_url_path="/static",
|
||||
static_folder="static",
|
||||
)
|
||||
SHUTDOWN_EVENT = asyncio.Event()
|
||||
|
||||
|
||||
def run(root: str, **app_kwargs: t.Any) -> None:
|
||||
@ -287,9 +290,32 @@ def run(root: str, **app_kwargs: t.Any) -> None:
|
||||
Bootstrap the Quart app and run it.
|
||||
"""
|
||||
TutorProject.connect(root)
|
||||
|
||||
# Manually shutdown application on SIGINT/SIGTERM
|
||||
# This does not work in development because the signal handler is overridden by app.run().
|
||||
# loop = asyncio.new_event_loop()
|
||||
# asyncio.set_event_loop(loop)
|
||||
# Monitor disconnection
|
||||
# loop.background_tasks.add(task)
|
||||
# app.background_tasks.add(task)
|
||||
|
||||
# import signal
|
||||
# for s in (signal.SIGINT, signal.SIGTERM):
|
||||
# loop.add_signal_handler(s, shutdown)
|
||||
|
||||
# TODO app.run() should be called only in development
|
||||
# app.run(loop=loop, **app_kwargs)
|
||||
app.run(**app_kwargs)
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
"""
|
||||
For now, this function is not called anywhere
|
||||
"""
|
||||
app.logger.info("Shutdown requested...")
|
||||
SHUTDOWN_EVENT.set()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def home() -> str:
|
||||
return await render_template("index.html", **shared_template_context())
|
||||
@ -308,34 +334,19 @@ async def plugin(name: str) -> str:
|
||||
|
||||
|
||||
@app.post("/plugin/<name>/toggle")
|
||||
async def toggle_plugin(name: str) -> dict[str, str]:
|
||||
async def toggle_plugin(name: str) -> WerkzeugResponse:
|
||||
# TODO check plugin exists
|
||||
form = await request.form
|
||||
enabled = form.get("enabled")
|
||||
if enabled not in ["on", "off"]:
|
||||
# TODO request validation. Can't we validate requests with a proper tool, such
|
||||
# as pydantic or a rest framework?
|
||||
return {}
|
||||
|
||||
# TODO actually toggle plugin
|
||||
app.logger.info("Toggling plugin %s", name)
|
||||
|
||||
return {}
|
||||
enable_plugin = form.get("enabled") == "on"
|
||||
return tutor_cli(["plugins", "enable" if enable_plugin else "disable", name])
|
||||
|
||||
|
||||
@app.post("/tutor/cli")
|
||||
async def tutor_cli() -> WerkzeugResponse:
|
||||
def tutor_cli(command: list[str]) -> WerkzeugResponse:
|
||||
# Run command asynchronously
|
||||
# if TutorCli.is_thread_alive():
|
||||
# TODO return 400 if thread is active
|
||||
# TODO parse command from JSON request body
|
||||
TutorCliPool.run_parallel(
|
||||
["dev", "start"],
|
||||
# ["dev", "dc", "run", "--no-deps", "lms", "bash"],
|
||||
# ["config", "printvalue", "DOCKER_IMAGE_OPENEDX"],
|
||||
# ["config", "printvalue", "POUAC"],
|
||||
# ["local", "launch", "--non-interactive"],
|
||||
)
|
||||
TutorCliPool.run_parallel(command)
|
||||
return redirect(url_for("tutor_cli_logs"))
|
||||
|
||||
|
||||
@ -351,18 +362,36 @@ async def tutor_cli_logs() -> str:
|
||||
|
||||
|
||||
@app.get("/tutor/cli/logs/stream")
|
||||
async def tutor_cli_logs_stream() -> None:
|
||||
# Websockets were not working for us in dev mode, we were unable to stop the server
|
||||
# as long as there were open connection. We only need single-direction
|
||||
# communication, so we use server-sent events
|
||||
# https://github.com/pallets/quart/issues/333
|
||||
# https://quart.palletsprojects.com/en/latest/how_to_guides/server_sent_events.html
|
||||
async def send_events():
|
||||
async def tutor_cli_logs_stream() -> ResponseTypes:
|
||||
"""
|
||||
We only need single-direction communication, so we use server-sent events, and not
|
||||
websockets.
|
||||
https://quart.palletsprojects.com/en/latest/how_to_guides/server_sent_events.html
|
||||
|
||||
Note that server interruption with ctrl+c does not work in Python 3.12 and 3.13
|
||||
because of this bug:
|
||||
https://github.com/pallets/quart/issues/333
|
||||
https://github.com/python/cpython/issues/123720
|
||||
|
||||
Events are sent with the following format:
|
||||
|
||||
data: "json-encoded string..."
|
||||
event: logs
|
||||
|
||||
Data is JSON-encoded such that we can sent newline characters, etc.
|
||||
"""
|
||||
|
||||
# TODO check that request accepts event stream (see howto)
|
||||
async def send_events() -> t.AsyncIterator[bytes]:
|
||||
while True:
|
||||
if SHUTDOWN_EVENT.is_set():
|
||||
# TODO explain this
|
||||
break
|
||||
# TODO this is again causing the stream to never stop...
|
||||
async for data in TutorCliPool.iter_logs():
|
||||
event = f"data: {data}\nevent: logs\n"
|
||||
# TODO encode one way or another to be able to send EOL characters and other weird chars
|
||||
# TODO important encode one way or another to be able to send EOL characters and other weird chars
|
||||
json_data = json.dumps(data)
|
||||
event = f"data: {json_data}\nevent: logs\n\n"
|
||||
yield event.encode()
|
||||
await asyncio.sleep(SHORT_SLEEP_SECONDS)
|
||||
|
||||
@ -374,7 +403,7 @@ async def tutor_cli_logs_stream() -> None:
|
||||
"Transfer-Encoding": "chunked",
|
||||
},
|
||||
)
|
||||
response.timeout = None
|
||||
setattr(response, "timeout", None)
|
||||
return response
|
||||
|
||||
|
||||
@ -386,4 +415,5 @@ def shared_template_context() -> dict[str, t.Any]:
|
||||
"""
|
||||
return {
|
||||
"installed_plugins": sorted(set(hooks.Filters.PLUGINS_INSTALLED.iterate())),
|
||||
"enabled_plugins": sorted(set(hooks.Filters.PLUGINS_LOADED.iterate())),
|
||||
}
|
||||
|
||||
@ -55,7 +55,7 @@ body {
|
||||
.workspace {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow: scroll;
|
||||
overflow: hidden;
|
||||
|
||||
.header {
|
||||
margin-bottom: 16px;
|
||||
@ -66,6 +66,14 @@ body {
|
||||
.content {
|
||||
outline: none;
|
||||
min-height: 200px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
padding-bottom: 20px; // necessary to display the bottom scrollbar
|
||||
|
||||
pre {
|
||||
overflow: scroll;
|
||||
height: calc(100% - 20px); // must match .content padding-bottom to display scrollbar
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,13 +43,8 @@
|
||||
<div class="content">
|
||||
<ul>
|
||||
{% for plugin in installed_plugins %}
|
||||
<li><a href="{{ url_for('plugin', name=plugin) }}">{{ plugin }}</a></li>
|
||||
<li><a href="{{ url_for('plugin', name=plugin) }}">{{ plugin }} {% if plugin in enabled_plugins %} ✅{% endif %}</a></li>
|
||||
{% endfor %}
|
||||
<form action="{{ url_for('tutor_cli') }}" method="POST">
|
||||
<input type="hidden" name="args[]" value="local">
|
||||
<input type="hidden" name="args[]" value="launch">
|
||||
<button type="submit">Apply changes</button>
|
||||
</form>
|
||||
<form action="{{ url_for('tutor_cli_stop') }}" method="POST">
|
||||
<button type="submit">Stop</button>
|
||||
</form>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
{% block workspace_header %}{{ plugin_name }}{% endblock %}
|
||||
|
||||
{% block workspace_content %}
|
||||
Enabled: <input type="checkbox" name="enabled" {% if is_enabled %}checked{% endif %}
|
||||
hx-post="{{ url_for('toggle_plugin', name=plugin_name) }}" />
|
||||
Enabled: <form method="POST" action="{{ url_for('toggle_plugin', name=plugin_name) }}">
|
||||
<input type="checkbox" name="enabled" onchange="this.form.submit()" {% if is_enabled %}checked{% endif %} />
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@ -8,14 +8,18 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// TODO fix me
|
||||
htmx.on("htmx:sseBeforeMessage", function(evt) {
|
||||
// Don't swap content, we want to append
|
||||
evt.preventDefault();
|
||||
|
||||
// Parse JSON
|
||||
const stdout = JSON.parse(evt.detail.data);
|
||||
|
||||
// Note that HTML is automatically escaped
|
||||
const text = document.createTextNode(evt.detail.data);
|
||||
const text = document.createTextNode(stdout);
|
||||
evt.detail.elt.appendChild(text);
|
||||
|
||||
// Scroll to bottom
|
||||
evt.detail.elt.scrollTop = evt.detail.elt.scrollHeight;
|
||||
});
|
||||
</script>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user