diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 70f58e6..f59a2f1 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -1319,6 +1319,7 @@ class Flomni( output_dir="~/data/raw/webpage/", #upload_url="http://s1090968537.online.de/upload.php", # optional upload_url="https://v1p0zyg2w9n2k9c1.myfritz.net/upload.php", + local_port=8080 ) self._webpage_gen.start() diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py index 31a87a5..7b13d47 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py @@ -5,10 +5,14 @@ Background thread that reads tomo progress from the BEC global variable store and writes status.json (every cycle) + status.html (once at startup) to a staging directory. An optional HttpUploader sends those files to a web host after every cycle, running in a separate daemon thread so uploads never block -the generator cycle. +the generator cycle. A built-in LocalHttpServer always serves the output +directory locally (default port 8080) so the page can be accessed on the +lab network without any extra setup. Architecture ------------ +LocalHttpServer -- built-in HTTP server; serves output_dir on port 8080. + Always started at _launch(); URL printed to console. HttpUploader -- non-blocking HTTP uploader (fire-and-forget thread). Tracks file mtimes; only uploads changed files. Sends a cleanup request to the server when the @@ -31,19 +35,24 @@ Integration (inside Flomni.__init__, after self._progress_proxy.reset()): bec_client=client, output_dir="~/data/raw/webpage/", upload_url="http://omny.online/upload.php", # optional + local_port=8080, # optional, default 8080 ) self._webpage_gen.start() + # On start(), the console prints: + # ➜ Status page: http://hostname:8080/status.html Interactive helpers (optional, in the iPython session): ------------------------------------------------------- - flomni._webpage_gen.status() # print current status + flomni._webpage_gen.status() # print current status + local URL flomni._webpage_gen.verbosity = 2 # VERBOSE: one-line summary per cycle flomni._webpage_gen.verbosity = 3 # DEBUG: full JSON per cycle - flomni._webpage_gen.stop() # release lock + flomni._webpage_gen.stop() # release lock, stop local server flomni._webpage_gen.start() # restart after stop() """ import datetime +import functools +import http.server import json import os import shutil @@ -364,6 +373,73 @@ class HttpUploader: self._warn("cleanup", f"HttpUploader cleanup failed: {exc}") +# --------------------------------------------------------------------------- +# Local HTTP server (serves output_dir over http://hostname:port/) +# --------------------------------------------------------------------------- + +class LocalHttpServer: + """ + Serves the generator's output directory over plain HTTP in a daemon thread. + + Uses Python's built-in http.server — no extra dependencies. + Request logging is suppressed so the BEC console stays clean. + The server survives stop()/start() cycles: _launch() creates a fresh + instance each time start() is called. + + Usage: + srv = LocalHttpServer(output_dir, port=8080) + srv.start() + print(srv.url) # http://hostname:8080/status.html + srv.stop() + """ + + def __init__(self, directory: Path, port: int = 8080): + self._directory = Path(directory) + self._port = port + self._server = None + self._thread = None + + # ── silence the per-request log lines in the iPython console ────────── + class _QuietHandler(http.server.SimpleHTTPRequestHandler): + def log_message(self, *args): + pass + + def start(self) -> None: + Handler = functools.partial( + self._QuietHandler, + directory=str(self._directory), + ) + try: + self._server = http.server.HTTPServer(("", self._port), Handler) + except OSError as exc: + raise RuntimeError( + f"LocalHttpServer: cannot bind port {self._port}: {exc}" + ) from exc + self._thread = threading.Thread( + target=self._server.serve_forever, + name="LocalHttpServer", + daemon=True, + ) + self._thread.start() + + def stop(self) -> None: + if self._server is not None: + self._server.shutdown() # blocks until serve_forever() returns + self._server = None + + def is_alive(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + @property + def port(self) -> int: + return self._port + + @property + def url(self) -> str: + """Best-guess URL for printing. Uses the machine's hostname.""" + return f"http://{socket.gethostname()}:{self._port}/status.html" + + # --------------------------------------------------------------------------- # Base generator # --------------------------------------------------------------------------- @@ -382,12 +458,15 @@ class WebpageGeneratorBase: cycle_interval: float = _CYCLE_INTERVAL_S, verbosity: int = VERBOSITY_NORMAL, upload_url: str = None, + local_port: int = 8080, ): self._bec = bec_client self._output_dir = Path(output_dir).expanduser().resolve() self._cycle_interval = cycle_interval self._verbosity = verbosity self._uploader = HttpUploader(upload_url) if upload_url else None + self._local_port = local_port + self._local_server = None # created fresh each _launch() self._thread = None self._stop_event = threading.Event() @@ -437,6 +516,17 @@ class WebpageGeneratorBase: self._copy_logo() (self._output_dir / "status.html").write_text(_render_html(_PHONE_NUMBERS)) + # Start local HTTP server (always on; a fresh instance per _launch). + if self._local_server is not None and self._local_server.is_alive(): + self._local_server.stop() + self._local_server = LocalHttpServer(self._output_dir, self._local_port) + try: + self._local_server.start() + local_url_msg = f" local={self._local_server.url}" + except RuntimeError as exc: + local_url_msg = f" local=ERROR({exc})" + self._log(VERBOSITY_NORMAL, str(exc), level="warning") + # Upload static files (html + logo) once at startup if self._uploader is not None: self._uploader.upload_dir_async(self._output_dir) @@ -449,13 +539,16 @@ class WebpageGeneratorBase: self._log(VERBOSITY_NORMAL, f"WebpageGenerator started owner={self._owner_id} " f"output={self._output_dir} interval={self._cycle_interval}s" - + (f" upload={self._uploader._url}" if self._uploader else " upload=disabled")) + + (f" upload={self._uploader._url}" if self._uploader else " upload=disabled") + + f"\n ➜ Status page:{local_url_msg}") def stop(self) -> None: - """Stop the generator thread and release the singleton lock.""" + """Stop the generator thread, local HTTP server, and release the singleton lock.""" self._stop_event.set() if self._thread is not None: self._thread.join(timeout=self._cycle_interval + 5) + if self._local_server is not None: + self._local_server.stop() self._release_lock() self._log(VERBOSITY_NORMAL, "WebpageGenerator stopped.") @@ -472,12 +565,14 @@ class WebpageGeneratorBase: """Print a human-readable status summary to the console.""" lock = self._read_lock() running = self._thread is not None and self._thread.is_alive() + local = self._local_server.url if (self._local_server and self._local_server.is_alive()) else "stopped" print( f"WebpageGenerator\n" f" This session running : {running}\n" f" Lock owner : {lock.get('owner_id', 'none')}\n" f" Lock heartbeat : {lock.get('heartbeat', 'never')}\n" f" Output dir : {self._output_dir}\n" + f" Local URL : {local}\n" f" Cycle interval : {self._cycle_interval}s\n" f" Upload URL : {self._uploader._url if self._uploader else 'disabled'}\n" f" Verbosity : {self._verbosity}\n" @@ -1982,7 +2077,7 @@ function render(d){{ async function poll(){{ try{{ - const r=await fetch(STATUS_JSON+'?t='+Date.now()); + const r=await fetch(STATUS_JSON, {{cache:'no-store'}}); if(!r.ok) throw new Error('HTTP '+r.status); render(await r.json()); }}catch(e){{