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:
		
							parent
							
								
									52a13d38e9
								
							
						
					
					
						commit
						e95bb5ea56
					
				|  | @ -1,6 +1,6 @@ | |||
| import datetime | ||||
| import os | ||||
| from typing import List, Dict, Union | ||||
| from typing import Dict, Iterator, List, Tuple, Union | ||||
| 
 | ||||
| import jinja2.exceptions | ||||
| 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)) | ||||
| 
 | ||||
| 
 | ||||
| def _read_log(path: str): | ||||
|     if os.path.exists(path): | ||||
|         with open(path, encoding="utf-8-sig") as 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." | ||||
| def _read_log(path: str, offset: int = 0) -> Iterator[bytes]: | ||||
|     with open(path, "rb") 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 | ||||
| 
 | ||||
| 
 | ||||
| @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) | ||||
|     if room is None: | ||||
|         return abort(404) | ||||
|     if room.owner == session["_id"]: | ||||
|         file_path = os.path.join("logs", str(room.id) + ".txt") | ||||
|         if os.path.exists(file_path): | ||||
|             return Response(_read_log(file_path), mimetype="text/plain;charset=UTF-8") | ||||
|         return "Log File does not exist." | ||||
|         try: | ||||
|             range_header = request.headers.get("Range") | ||||
|             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 | ||||
| 
 | ||||
|  | @ -139,7 +150,21 @@ def host_room(room: UUID): | |||
|     with db_session: | ||||
|         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') | ||||
|  |  | |||
|  | @ -44,7 +44,7 @@ | |||
|         {{ macros.list_patches_room(room) }} | ||||
|         {% if room.owner == session["_id"] %} | ||||
|             <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"> | ||||
|                         <label for="cmd"></label> | ||||
|                         <input class="form-control" type="text" id="cmd" name="cmd" | ||||
|  | @ -55,24 +55,89 @@ | |||
|                     Open Log File... | ||||
|                 </a> | ||||
|             </div> | ||||
|         <div id="logger"></div> | ||||
|         <script type="application/ecmascript"> | ||||
|             let xmlhttp = new XMLHttpRequest(); | ||||
|             let url = '{{ url_for('display_log', room = room.id) }}'; | ||||
|         {%  set log = get_log() -%} | ||||
|         {%- set log_len = log | length - 1 if log.endswith("…") else log | length -%} | ||||
|         <div id="logger" style="white-space: pre">{{ log }}</div> | ||||
|         <script> | ||||
|           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 () { | ||||
|                 if (this.readyState === 4 && this.status === 200) { | ||||
|                     document.getElementById("logger").innerText = this.responseText; | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             function request_new() { | ||||
|                 xmlhttp.open("GET", url, true); | ||||
|                 xmlhttp.send(); | ||||
|           function scrollToBottom(el) { | ||||
|             let bot = el.scrollHeight - el.clientHeight; | ||||
|             el.scrollTop += Math.ceil((bot - el.scrollTop)/10); | ||||
|             if (bot - el.scrollTop >= 1) { | ||||
|               window.clearTimeout(el.scrollTimer); | ||||
|               el.scrollTimer = window.setTimeout(() => { | ||||
|                 scrollToBottom(el) | ||||
|               }, 16); | ||||
|             } | ||||
|           } | ||||
| 
 | ||||
|             window.setTimeout(request_new, 1000); | ||||
|             window.setInterval(request_new, 10000); | ||||
|           async function updateLog() { | ||||
|             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> | ||||
|         {% endif %} | ||||
|     </div> | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue