WebHost: Better host room (#3496)

* add Range= to log, making responses a lot smaller for massive rooms
* switch xhr to fetch
* post the form using fetch if possible
  * also refresh log faster while waiting for command echo / response
  * do not follow redirect, saving a request
  * do not post empty body
* smooth-scroll the log view
* paste the log into the div when loading the HTML (up to 1MB, rest will be `fetch`ed)
* fix duplicate charset in display_log response
This commit is contained in:
black-sliver 2024-07-01 21:47:49 +02:00 committed by GitHub
parent 52a13d38e9
commit e95bb5ea56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 119 additions and 29 deletions

View File

@ -1,6 +1,6 @@
import datetime import datetime
import os import os
from typing import List, Dict, Union from typing import Dict, Iterator, List, Tuple, Union
import jinja2.exceptions import jinja2.exceptions
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
@ -97,25 +97,36 @@ def new_room(seed: UUID):
return redirect(url_for("host_room", room=room.id)) return redirect(url_for("host_room", room=room.id))
def _read_log(path: str): def _read_log(path: str, offset: int = 0) -> Iterator[bytes]:
if os.path.exists(path): with open(path, "rb") as log:
with open(path, encoding="utf-8-sig") as log: marker = log.read(3) # skip optional BOM
if marker != b'\xEF\xBB\xBF':
log.seek(0, os.SEEK_SET)
log.seek(offset, os.SEEK_CUR)
yield from log yield from log
else:
yield f"Logfile {path} does not exist. " \
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
@app.route('/log/<suuid:room>') @app.route('/log/<suuid:room>')
def display_log(room: UUID): def display_log(room: UUID) -> Union[str, Response, Tuple[str, int]]:
room = Room.get(id=room) room = Room.get(id=room)
if room is None: if room is None:
return abort(404) return abort(404)
if room.owner == session["_id"]: if room.owner == session["_id"]:
file_path = os.path.join("logs", str(room.id) + ".txt") file_path = os.path.join("logs", str(room.id) + ".txt")
if os.path.exists(file_path): try:
return Response(_read_log(file_path), mimetype="text/plain;charset=UTF-8") range_header = request.headers.get("Range")
return "Log File does not exist." if range_header:
range_type, range_values = range_header.split('=')
start, end = map(str.strip, range_values.split('-', 1))
if range_type != "bytes" or end != "":
return "Unsupported range", 500
# NOTE: we skip Content-Range in the response here, which isn't great but works for our JS
return Response(_read_log(file_path, int(start)), mimetype="text/plain", status=206)
return Response(_read_log(file_path), mimetype="text/plain")
except FileNotFoundError:
return Response(f"Logfile {file_path} does not exist. "
f"Likely a crash during spinup of multiworld instance or it is still spinning up.",
mimetype="text/plain")
return "Access Denied", 403 return "Access Denied", 403
@ -139,7 +150,21 @@ def host_room(room: UUID):
with db_session: with db_session:
room.last_activity = now # will trigger a spinup, if it's not already running room.last_activity = now # will trigger a spinup, if it's not already running
return render_template("hostRoom.html", room=room, should_refresh=should_refresh) def get_log(max_size: int = 1024000) -> str:
try:
raw_size = 0
fragments: List[str] = []
for block in _read_log(os.path.join("logs", str(room.id) + ".txt")):
if raw_size + len(block) > max_size:
fragments.append("")
break
raw_size += len(block)
fragments.append(block.decode("utf-8"))
return "".join(fragments)
except FileNotFoundError:
return ""
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)
@app.route('/favicon.ico') @app.route('/favicon.ico')

View File

@ -44,7 +44,7 @@
{{ macros.list_patches_room(room) }} {{ macros.list_patches_room(room) }}
{% if room.owner == session["_id"] %} {% if room.owner == session["_id"] %}
<div style="display: flex; align-items: center;"> <div style="display: flex; align-items: center;">
<form method=post style="flex-grow: 1; margin-right: 1em;"> <form method="post" id="command-form" style="flex-grow: 1; margin-right: 1em;">
<div class="form-group"> <div class="form-group">
<label for="cmd"></label> <label for="cmd"></label>
<input class="form-control" type="text" id="cmd" name="cmd" <input class="form-control" type="text" id="cmd" name="cmd"
@ -55,24 +55,89 @@
Open Log File... Open Log File...
</a> </a>
</div> </div>
<div id="logger"></div> {% set log = get_log() -%}
<script type="application/ecmascript"> {%- set log_len = log | length - 1 if log.endswith("…") else log | length -%}
let xmlhttp = new XMLHttpRequest(); <div id="logger" style="white-space: pre">{{ log }}</div>
<script>
let url = '{{ url_for('display_log', room = room.id) }}'; let url = '{{ url_for('display_log', room = room.id) }}';
let bytesReceived = {{ log_len }};
let updateLogTimeout;
let awaitingCommandResponse = false;
let logger = document.getElementById("logger");
xmlhttp.onreadystatechange = function () { function scrollToBottom(el) {
if (this.readyState === 4 && this.status === 200) { let bot = el.scrollHeight - el.clientHeight;
document.getElementById("logger").innerText = this.responseText; el.scrollTop += Math.ceil((bot - el.scrollTop)/10);
if (bot - el.scrollTop >= 1) {
window.clearTimeout(el.scrollTimer);
el.scrollTimer = window.setTimeout(() => {
scrollToBottom(el)
}, 16);
} }
};
function request_new() {
xmlhttp.open("GET", url, true);
xmlhttp.send();
} }
window.setTimeout(request_new, 1000); async function updateLog() {
window.setInterval(request_new, 10000); try {
let res = await fetch(url, {
headers: {
'Range': `bytes=${bytesReceived}-`,
}
});
if (res.ok) {
let text = await res.text();
if (text.length > 0) {
awaitingCommandResponse = false;
if (bytesReceived === 0 || res.status !== 206) {
logger.innerHTML = '';
}
if (res.status !== 206) {
bytesReceived = 0;
} else {
bytesReceived += new Blob([text]).size;
}
if (logger.innerHTML.endsWith('…')) {
logger.innerHTML = logger.innerHTML.substring(0, logger.innerHTML.length - 1);
}
logger.appendChild(document.createTextNode(text));
scrollToBottom(logger);
}
}
}
finally {
window.clearTimeout(updateLogTimeout);
updateLogTimeout = window.setTimeout(updateLog, awaitingCommandResponse ? 500 : 10000);
}
}
async function postForm(ev) {
/** @type {HTMLInputElement} */
let cmd = document.getElementById("cmd");
if (cmd.value === "") {
ev.preventDefault();
return;
}
/** @type {HTMLFormElement} */
let form = document.getElementById("command-form");
let req = fetch(form.action || window.location.href, {
method: form.method,
body: new FormData(form),
redirect: "manual",
});
ev.preventDefault(); // has to happen before first await
form.reset();
let res = await req;
if (res.ok || res.type === 'opaqueredirect') {
awaitingCommandResponse = true;
window.clearTimeout(updateLogTimeout);
updateLogTimeout = window.setTimeout(updateLog, 100);
} else {
window.alert(res.statusText);
}
}
document.getElementById("command-form").addEventListener("submit", postForm);
updateLogTimeout = window.setTimeout(updateLog, 1000);
logger.scrollTop = logger.scrollHeight;
</script> </script>
{% endif %} {% endif %}
</div> </div>