|
|
|
|
@@ -0,0 +1,892 @@
|
|
|
|
|
"""
|
|
|
|
|
webpage_generator.py
|
|
|
|
|
====================
|
|
|
|
|
Background thread that reads the flOMNI tomo progress from the BEC global
|
|
|
|
|
variable store and writes a self-contained status.json + status.html to a
|
|
|
|
|
configurable output directory. A separate upload process can copy those
|
|
|
|
|
files to the web host.
|
|
|
|
|
|
|
|
|
|
Usage (inside Flomni.__init__, after self._progress_proxy.reset()):
|
|
|
|
|
--------------------------------------------------------------------
|
|
|
|
|
self._webpage_gen = WebpageGenerator(
|
|
|
|
|
bec_client=client,
|
|
|
|
|
output_dir="~/data/raw/webpage/",
|
|
|
|
|
)
|
|
|
|
|
self._webpage_gen.start()
|
|
|
|
|
|
|
|
|
|
Interactive commands (optional, in the iPython session):
|
|
|
|
|
---------------------------------------------------------
|
|
|
|
|
flomni._webpage_gen.status() # print current status
|
|
|
|
|
flomni._webpage_gen.verbosity = 2 # switch to VERBOSE mid-session
|
|
|
|
|
flomni._webpage_gen.stop() # release lock, let another session take over
|
|
|
|
|
flomni._webpage_gen.start() # restart after stop()
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import datetime
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import socket
|
|
|
|
|
import threading
|
|
|
|
|
import time
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from bec_lib import bec_logger
|
|
|
|
|
|
|
|
|
|
logger = bec_logger.logger
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Constants
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
# BEC global-var key used as a distributed singleton lock
|
|
|
|
|
_LOCK_VAR_KEY = "webpage_generator_lock"
|
|
|
|
|
|
|
|
|
|
# Heartbeat must be refreshed at least this often (seconds) or the lock
|
|
|
|
|
# is considered stale and another session may take over.
|
|
|
|
|
_LOCK_STALE_AFTER_S = 45
|
|
|
|
|
|
|
|
|
|
# How long between generator cycles (seconds)
|
|
|
|
|
_CYCLE_INTERVAL_S = 15
|
|
|
|
|
|
|
|
|
|
# If the tomo progress heartbeat has not been updated for this long we
|
|
|
|
|
# consider the tomo loop no longer actively running.
|
|
|
|
|
_TOMO_HEARTBEAT_STALE_S = 90
|
|
|
|
|
|
|
|
|
|
# After finishing normally, stay in IDLE_SHORT for this long before
|
|
|
|
|
# switching to IDLE_LONG (which triggers the audio warning).
|
|
|
|
|
_IDLE_SHORT_WINDOW_S = 300 # 5 minutes
|
|
|
|
|
|
|
|
|
|
# Verbosity levels
|
|
|
|
|
VERBOSITY_SILENT = 0 # no output at all
|
|
|
|
|
VERBOSITY_NORMAL = 1 # startup/stop messages only
|
|
|
|
|
VERBOSITY_VERBOSE = 2 # each cycle summary
|
|
|
|
|
VERBOSITY_DEBUG = 3 # full detail each cycle
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Helpers
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _now_iso() -> str:
|
|
|
|
|
return datetime.datetime.now().isoformat(timespec="seconds")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _epoch() -> float:
|
|
|
|
|
return time.time()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _heartbeat_age_s(iso_str) -> float:
|
|
|
|
|
"""Return seconds since the ISO-format heartbeat string, or infinity."""
|
|
|
|
|
if iso_str is None:
|
|
|
|
|
return float("inf")
|
|
|
|
|
try:
|
|
|
|
|
ts = datetime.datetime.fromisoformat(iso_str)
|
|
|
|
|
return (datetime.datetime.now() - ts).total_seconds()
|
|
|
|
|
except Exception:
|
|
|
|
|
return float("inf")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _format_duration(seconds) -> str:
|
|
|
|
|
if seconds is None:
|
|
|
|
|
return "N/A"
|
|
|
|
|
try:
|
|
|
|
|
seconds = int(float(seconds))
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
return "N/A"
|
|
|
|
|
h, remainder = divmod(seconds, 3600)
|
|
|
|
|
m, s = divmod(remainder, 60)
|
|
|
|
|
if h > 0:
|
|
|
|
|
return f"{h}h {m:02d}m {s:02d}s"
|
|
|
|
|
if m > 0:
|
|
|
|
|
return f"{m}m {s:02d}s"
|
|
|
|
|
return f"{s}s"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _check_account_match(bec_client) -> bool:
|
|
|
|
|
"""Return True if the BEC active account matches the system user."""
|
|
|
|
|
try:
|
|
|
|
|
active = bec_client.active_account # e.g. "p23092"
|
|
|
|
|
system_user = os.getenv("USER") or os.getlogin() # e.g. "e23092"
|
|
|
|
|
return active[1:] == system_user[1:]
|
|
|
|
|
except Exception:
|
|
|
|
|
return True # don't block on unknown accounts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Status derivation
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _derive_status(progress: dict, queue_has_active_scan: bool, idle_since) -> str:
|
|
|
|
|
"""
|
|
|
|
|
Derive a simple status string from available signals.
|
|
|
|
|
|
|
|
|
|
Returns one of:
|
|
|
|
|
"scanning" - tomo heartbeat is fresh (tomo loop actively running)
|
|
|
|
|
"running" - a scan is active but outside the tomo heartbeat window
|
|
|
|
|
(alignment, other tasks, or brief inter-scan gap)
|
|
|
|
|
"idle_short" - recently finished, within IDLE_SHORT_WINDOW_S
|
|
|
|
|
"idle_long" - idle longer than IDLE_SHORT_WINDOW_S (trigger warning)
|
|
|
|
|
"unknown" - cannot determine yet
|
|
|
|
|
"""
|
|
|
|
|
hb_age = _heartbeat_age_s(progress.get("heartbeat"))
|
|
|
|
|
tomo_active = hb_age < _TOMO_HEARTBEAT_STALE_S
|
|
|
|
|
|
|
|
|
|
if tomo_active:
|
|
|
|
|
return "scanning"
|
|
|
|
|
|
|
|
|
|
if queue_has_active_scan:
|
|
|
|
|
return "running"
|
|
|
|
|
|
|
|
|
|
if idle_since is not None:
|
|
|
|
|
idle_s = _epoch() - idle_since
|
|
|
|
|
return "idle_short" if idle_s < _IDLE_SHORT_WINDOW_S else "idle_long"
|
|
|
|
|
|
|
|
|
|
return "unknown"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Main generator class
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
class WebpageGenerator:
|
|
|
|
|
"""
|
|
|
|
|
Singleton-safe background thread that generates the experiment status
|
|
|
|
|
page by reading BEC global variables.
|
|
|
|
|
|
|
|
|
|
Parameters
|
|
|
|
|
----------
|
|
|
|
|
bec_client : BECClient
|
|
|
|
|
The active BEC client instance (``bec`` in the iPython session).
|
|
|
|
|
output_dir : str | Path
|
|
|
|
|
Directory where ``status.json`` and ``status.html`` are written.
|
|
|
|
|
Created if it does not exist.
|
|
|
|
|
cycle_interval : float
|
|
|
|
|
Seconds between update cycles. Default: 15 s.
|
|
|
|
|
verbosity : int
|
|
|
|
|
VERBOSITY_SILENT / VERBOSITY_NORMAL / VERBOSITY_VERBOSE / VERBOSITY_DEBUG.
|
|
|
|
|
Default: VERBOSITY_NORMAL.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
bec_client,
|
|
|
|
|
output_dir: str = "~/data/raw/webpage/",
|
|
|
|
|
cycle_interval: float = _CYCLE_INTERVAL_S,
|
|
|
|
|
verbosity: int = VERBOSITY_NORMAL,
|
|
|
|
|
):
|
|
|
|
|
self._bec = bec_client
|
|
|
|
|
self._output_dir = Path(output_dir).expanduser().resolve()
|
|
|
|
|
self._cycle_interval = cycle_interval
|
|
|
|
|
self._verbosity = verbosity
|
|
|
|
|
|
|
|
|
|
self._thread = None
|
|
|
|
|
self._stop_event = threading.Event()
|
|
|
|
|
|
|
|
|
|
# Rolling state kept between cycles
|
|
|
|
|
self._idle_since = None
|
|
|
|
|
self._owner_id = f"{socket.gethostname()}:{os.getpid()}"
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Public API
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def start(self) -> bool:
|
|
|
|
|
"""
|
|
|
|
|
Start the generator thread if this session wins the singleton lock.
|
|
|
|
|
Returns True if started, False if another session already owns it.
|
|
|
|
|
"""
|
|
|
|
|
if not _check_account_match(self._bec):
|
|
|
|
|
self._log(
|
|
|
|
|
VERBOSITY_NORMAL,
|
|
|
|
|
"WebpageGenerator: BEC account does not match system user. "
|
|
|
|
|
"Not starting to avoid writing data to the wrong account.",
|
|
|
|
|
level="warning",
|
|
|
|
|
)
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
if self._thread is not None and self._thread.is_alive():
|
|
|
|
|
self._log(VERBOSITY_NORMAL, "WebpageGenerator already running in this session.")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if not self._acquire_lock():
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
self._output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
self._stop_event.clear()
|
|
|
|
|
self._thread = threading.Thread(
|
|
|
|
|
target=self._run,
|
|
|
|
|
name="WebpageGenerator",
|
|
|
|
|
daemon=True,
|
|
|
|
|
)
|
|
|
|
|
self._thread.start()
|
|
|
|
|
self._log(
|
|
|
|
|
VERBOSITY_NORMAL,
|
|
|
|
|
f"WebpageGenerator started (owner: {self._owner_id}, "
|
|
|
|
|
f"output: {self._output_dir}, interval: {self._cycle_interval}s)",
|
|
|
|
|
)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def stop(self) -> None:
|
|
|
|
|
"""Stop the generator thread and release the singleton lock."""
|
|
|
|
|
self._stop_event.set()
|
|
|
|
|
if self._thread is not None:
|
|
|
|
|
self._thread.join(timeout=self._cycle_interval + 5)
|
|
|
|
|
self._release_lock()
|
|
|
|
|
self._log(VERBOSITY_NORMAL, "WebpageGenerator stopped.")
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def verbosity(self) -> int:
|
|
|
|
|
return self._verbosity
|
|
|
|
|
|
|
|
|
|
@verbosity.setter
|
|
|
|
|
def verbosity(self, val: int) -> None:
|
|
|
|
|
self._verbosity = val
|
|
|
|
|
self._log(VERBOSITY_NORMAL, f"WebpageGenerator verbosity set to {val}.")
|
|
|
|
|
|
|
|
|
|
def status(self) -> None:
|
|
|
|
|
"""Print a human-readable status summary to the console."""
|
|
|
|
|
lock = self._read_lock()
|
|
|
|
|
running = self._thread is not None and self._thread.is_alive()
|
|
|
|
|
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" Cycle interval : {self._cycle_interval}s\n"
|
|
|
|
|
f" Verbosity : {self._verbosity}\n"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Singleton lock helpers
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _acquire_lock(self) -> bool:
|
|
|
|
|
lock = self._read_lock()
|
|
|
|
|
if lock:
|
|
|
|
|
age = _heartbeat_age_s(lock.get("heartbeat"))
|
|
|
|
|
if age < _LOCK_STALE_AFTER_S:
|
|
|
|
|
self._log(
|
|
|
|
|
VERBOSITY_NORMAL,
|
|
|
|
|
f"WebpageGenerator already owned by "
|
|
|
|
|
f"'{lock.get('owner_id')}' "
|
|
|
|
|
f"(heartbeat {age:.0f}s ago). Not starting.",
|
|
|
|
|
)
|
|
|
|
|
return False
|
|
|
|
|
self._log(
|
|
|
|
|
VERBOSITY_NORMAL,
|
|
|
|
|
f"Stale lock found (owner: '{lock.get('owner_id')}', "
|
|
|
|
|
f"{age:.0f}s ago). Taking over.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self._write_lock()
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def _write_lock(self) -> None:
|
|
|
|
|
self._bec.set_global_var(
|
|
|
|
|
_LOCK_VAR_KEY,
|
|
|
|
|
{
|
|
|
|
|
"owner_id": self._owner_id,
|
|
|
|
|
"heartbeat": _now_iso(),
|
|
|
|
|
"pid": os.getpid(),
|
|
|
|
|
"hostname": socket.gethostname(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _read_lock(self) -> dict:
|
|
|
|
|
val = self._bec.get_global_var(_LOCK_VAR_KEY)
|
|
|
|
|
return val if isinstance(val, dict) else {}
|
|
|
|
|
|
|
|
|
|
def _release_lock(self) -> None:
|
|
|
|
|
lock = self._read_lock()
|
|
|
|
|
if lock.get("owner_id") == self._owner_id:
|
|
|
|
|
self._bec.delete_global_var(_LOCK_VAR_KEY)
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Main loop
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _run(self) -> None:
|
|
|
|
|
while not self._stop_event.is_set():
|
|
|
|
|
cycle_start = _epoch()
|
|
|
|
|
try:
|
|
|
|
|
self._cycle()
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
self._log(
|
|
|
|
|
VERBOSITY_NORMAL,
|
|
|
|
|
f"WebpageGenerator cycle error: {exc}",
|
|
|
|
|
level="warning",
|
|
|
|
|
)
|
|
|
|
|
# Refresh the singleton heartbeat
|
|
|
|
|
try:
|
|
|
|
|
self._write_lock()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# Sleep for the remainder of the interval
|
|
|
|
|
elapsed = _epoch() - cycle_start
|
|
|
|
|
sleep_time = max(0.0, self._cycle_interval - elapsed)
|
|
|
|
|
self._stop_event.wait(sleep_time)
|
|
|
|
|
|
|
|
|
|
def _cycle(self) -> None:
|
|
|
|
|
"""One generator cycle: read state -> derive status -> write outputs."""
|
|
|
|
|
|
|
|
|
|
# --- Read progress from global var (readable from any session) -------
|
|
|
|
|
progress = self._bec.get_global_var("tomo_progress") or {}
|
|
|
|
|
|
|
|
|
|
# --- Read queue status -----------------------------------------------
|
|
|
|
|
# NOTE: queue status is always 'RUNNING' while BEC is alive.
|
|
|
|
|
# An actual scan is executing only when info is non-empty AND
|
|
|
|
|
# active_request_block is set on the first entry.
|
|
|
|
|
try:
|
|
|
|
|
queue_info = self._bec.queue.queue_storage.current_scan_queue
|
|
|
|
|
primary = queue_info.get("primary")
|
|
|
|
|
queue_status = primary.status if primary is not None else "unknown"
|
|
|
|
|
queue_has_active_scan = (
|
|
|
|
|
primary is not None
|
|
|
|
|
and len(primary.info) > 0
|
|
|
|
|
and primary.info[0].active_request_block is not None
|
|
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
queue_status = "unknown"
|
|
|
|
|
queue_has_active_scan = False
|
|
|
|
|
|
|
|
|
|
# --- Track idle onset ------------------------------------------------
|
|
|
|
|
# Use both the tomo heartbeat and the queue active-scan flag.
|
|
|
|
|
# This handles the brief COMPLETED gap between individual scans
|
|
|
|
|
# while a tomo is still running.
|
|
|
|
|
hb_age = _heartbeat_age_s(progress.get("heartbeat"))
|
|
|
|
|
tomo_active = hb_age < _TOMO_HEARTBEAT_STALE_S
|
|
|
|
|
|
|
|
|
|
if tomo_active or queue_has_active_scan:
|
|
|
|
|
self._idle_since = None
|
|
|
|
|
elif self._idle_since is None:
|
|
|
|
|
self._idle_since = _epoch()
|
|
|
|
|
|
|
|
|
|
# --- Derive experiment status ----------------------------------------
|
|
|
|
|
exp_status = _derive_status(progress, queue_has_active_scan, self._idle_since)
|
|
|
|
|
|
|
|
|
|
# --- Build payload ---------------------------------------------------
|
|
|
|
|
idle_for_s = None if self._idle_since is None else (_epoch() - self._idle_since)
|
|
|
|
|
|
|
|
|
|
payload = {
|
|
|
|
|
"generated_at": _now_iso(),
|
|
|
|
|
"generated_at_epoch": _epoch(),
|
|
|
|
|
"experiment_status": exp_status,
|
|
|
|
|
"queue_status": queue_status,
|
|
|
|
|
"queue_has_active_scan": queue_has_active_scan,
|
|
|
|
|
"idle_for_s": idle_for_s,
|
|
|
|
|
"idle_for_human": _format_duration(idle_for_s),
|
|
|
|
|
"progress": {
|
|
|
|
|
"tomo_type": progress.get("tomo_type", "N/A"),
|
|
|
|
|
"projection": progress.get("projection", 0),
|
|
|
|
|
"total_projections": progress.get("total_projections", 0),
|
|
|
|
|
"subtomo": progress.get("subtomo", 0),
|
|
|
|
|
"subtomo_projection": progress.get("subtomo_projection", 0),
|
|
|
|
|
"subtomo_total_projections": progress.get("subtomo_total_projections", 1),
|
|
|
|
|
"angle": progress.get("angle", 0),
|
|
|
|
|
"tomo_start_time": progress.get("tomo_start_time"),
|
|
|
|
|
"estimated_remaining_s": progress.get("estimated_remaining_time"),
|
|
|
|
|
"estimated_remaining_human": _format_duration(
|
|
|
|
|
progress.get("estimated_remaining_time")
|
|
|
|
|
),
|
|
|
|
|
"heartbeat": progress.get("heartbeat"),
|
|
|
|
|
"heartbeat_age_s": round(hb_age, 1) if hb_age != float("inf") else None,
|
|
|
|
|
},
|
|
|
|
|
"generator": {
|
|
|
|
|
"owner_id": self._owner_id,
|
|
|
|
|
"cycle_interval_s": self._cycle_interval,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# --- Write outputs ---------------------------------------------------
|
|
|
|
|
json_path = self._output_dir / "status.json"
|
|
|
|
|
json_path.write_text(json.dumps(payload, indent=2, default=str))
|
|
|
|
|
|
|
|
|
|
html_path = self._output_dir / "status.html"
|
|
|
|
|
html_path.write_text(_render_html())
|
|
|
|
|
|
|
|
|
|
# --- Console feedback ------------------------------------------------
|
|
|
|
|
self._log(
|
|
|
|
|
VERBOSITY_VERBOSE,
|
|
|
|
|
f"[{_now_iso()}] status={exp_status} active_scan={queue_has_active_scan} "
|
|
|
|
|
f"proj={payload['progress']['projection']}/"
|
|
|
|
|
f"{payload['progress']['total_projections']} "
|
|
|
|
|
f"hb_age={payload['progress']['heartbeat_age_s']}s "
|
|
|
|
|
f"idle={_format_duration(idle_for_s)}",
|
|
|
|
|
)
|
|
|
|
|
self._log(
|
|
|
|
|
VERBOSITY_DEBUG,
|
|
|
|
|
f" full payload:\n{json.dumps(payload, indent=4, default=str)}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Logging helper
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _log(self, min_verbosity: int, msg: str, level: str = "info") -> None:
|
|
|
|
|
if self._verbosity < min_verbosity:
|
|
|
|
|
return
|
|
|
|
|
if level == "warning":
|
|
|
|
|
logger.warning(msg)
|
|
|
|
|
elif level == "error":
|
|
|
|
|
logger.error(msg)
|
|
|
|
|
else:
|
|
|
|
|
print(msg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# HTML template (static shell - the page fetches status.json on load/refresh)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _render_html() -> str:
|
|
|
|
|
"""Return the full HTML for the status page."""
|
|
|
|
|
return r"""<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
<title>flOMNI - Experiment Status</title>
|
|
|
|
|
<style>
|
|
|
|
|
@import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:wght@300;400;600&display=swap');
|
|
|
|
|
|
|
|
|
|
:root {
|
|
|
|
|
--bg: #0d0f14;
|
|
|
|
|
--surface: #161a23;
|
|
|
|
|
--surface2: #1e2330;
|
|
|
|
|
--border: #2a3045;
|
|
|
|
|
--text: #cdd6f4;
|
|
|
|
|
--text-dim: #6c7a9c;
|
|
|
|
|
--mono: 'Space Mono', monospace;
|
|
|
|
|
--sans: 'DM Sans', sans-serif;
|
|
|
|
|
--c-scanning: #89dceb;
|
|
|
|
|
--c-running: #a6e3a1;
|
|
|
|
|
--c-idle-short: #f9e2af;
|
|
|
|
|
--c-idle-long: #fab387;
|
|
|
|
|
--c-error: #f38ba8;
|
|
|
|
|
--c-unknown: #6c7a9c;
|
|
|
|
|
--status-color: var(--c-unknown);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body.scanning { --status-color: var(--c-scanning); }
|
|
|
|
|
body.running { --status-color: var(--c-running); }
|
|
|
|
|
body.idle_short { --status-color: var(--c-idle-short);}
|
|
|
|
|
body.idle_long { --status-color: var(--c-idle-long); }
|
|
|
|
|
body.error { --status-color: var(--c-error); }
|
|
|
|
|
body.unknown { --status-color: var(--c-unknown); }
|
|
|
|
|
|
|
|
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
|
|
|
|
|
|
body {
|
|
|
|
|
background: var(--bg);
|
|
|
|
|
color: var(--text);
|
|
|
|
|
font-family: var(--sans);
|
|
|
|
|
font-weight: 300;
|
|
|
|
|
min-height: 100vh;
|
|
|
|
|
padding: 2rem;
|
|
|
|
|
transition: background 0.6s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body::before {
|
|
|
|
|
content: '';
|
|
|
|
|
position: fixed; inset: 0;
|
|
|
|
|
background: radial-gradient(ellipse 80% 50% at 50% -10%,
|
|
|
|
|
color-mix(in srgb, var(--status-color) 8%, transparent), transparent);
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
transition: background 0.8s ease;
|
|
|
|
|
z-index: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.layout {
|
|
|
|
|
position: relative; z-index: 1;
|
|
|
|
|
max-width: 860px;
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 1.5rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
header {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: baseline;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
padding-bottom: 1rem;
|
|
|
|
|
border-bottom: 1px solid var(--border);
|
|
|
|
|
}
|
|
|
|
|
.logo {
|
|
|
|
|
font-family: var(--mono);
|
|
|
|
|
font-size: 1.1rem;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
letter-spacing: 0.15em;
|
|
|
|
|
color: var(--text-dim);
|
|
|
|
|
}
|
|
|
|
|
.logo span { color: var(--status-color); transition: color 0.6s; }
|
|
|
|
|
#last-update { font-family: var(--mono); font-size: 0.7rem; color: var(--text-dim); }
|
|
|
|
|
|
|
|
|
|
.status-card {
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
border-left: 3px solid var(--status-color);
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
padding: 1.5rem 2rem;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 2rem;
|
|
|
|
|
transition: border-color 0.6s;
|
|
|
|
|
}
|
|
|
|
|
.status-pill {
|
|
|
|
|
font-family: var(--mono);
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
letter-spacing: 0.12em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
color: var(--bg);
|
|
|
|
|
background: var(--status-color);
|
|
|
|
|
padding: 0.3rem 0.9rem;
|
|
|
|
|
border-radius: 100px;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
transition: background 0.6s;
|
|
|
|
|
}
|
|
|
|
|
.status-detail { flex: 1; font-size: 0.9rem; color: var(--text-dim); line-height: 1.6; }
|
|
|
|
|
.status-detail strong { color: var(--text); font-weight: 600; }
|
|
|
|
|
|
|
|
|
|
.progress-card {
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
padding: 1.5rem 2rem;
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: auto 1fr;
|
|
|
|
|
gap: 1.5rem 2.5rem;
|
|
|
|
|
align-items: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.rings-wrap { position: relative; width: 120px; height: 120px; flex-shrink: 0; }
|
|
|
|
|
.rings-wrap svg { width: 100%; height: 100%; transform: rotate(-90deg); }
|
|
|
|
|
.ring-track { fill: none; stroke: var(--surface2); }
|
|
|
|
|
.ring-outer {
|
|
|
|
|
fill: none; stroke: var(--status-color); stroke-linecap: round; opacity: 0.9;
|
|
|
|
|
transition: stroke-dashoffset 0.8s cubic-bezier(.4,0,.2,1), stroke 0.6s;
|
|
|
|
|
}
|
|
|
|
|
.ring-inner {
|
|
|
|
|
fill: none;
|
|
|
|
|
stroke: color-mix(in srgb, var(--status-color) 55%, var(--surface2));
|
|
|
|
|
stroke-linecap: round;
|
|
|
|
|
transition: stroke-dashoffset 0.8s cubic-bezier(.4,0,.2,1), stroke 0.6s;
|
|
|
|
|
}
|
|
|
|
|
.ring-label {
|
|
|
|
|
position: absolute; inset: 0;
|
|
|
|
|
display: flex; flex-direction: column;
|
|
|
|
|
align-items: center; justify-content: center;
|
|
|
|
|
font-family: var(--mono);
|
|
|
|
|
}
|
|
|
|
|
.ring-label .pct { font-size: 1.3rem; font-weight: 700; color: var(--text); }
|
|
|
|
|
.ring-label .sublbl { font-size: 0.6rem; color: var(--text-dim); letter-spacing: 0.06em; }
|
|
|
|
|
|
|
|
|
|
.progress-info { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem 2rem; }
|
|
|
|
|
.info-item { display: flex; flex-direction: column; gap: 0.15rem; }
|
|
|
|
|
.info-item .label {
|
|
|
|
|
font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim);
|
|
|
|
|
letter-spacing: 0.08em; text-transform: uppercase;
|
|
|
|
|
}
|
|
|
|
|
.info-item .value { font-size: 0.95rem; font-weight: 600; color: var(--text); }
|
|
|
|
|
|
|
|
|
|
.bar-wrap { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 0.4rem; }
|
|
|
|
|
.bar-label {
|
|
|
|
|
display: flex; justify-content: space-between;
|
|
|
|
|
font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim);
|
|
|
|
|
letter-spacing: 0.06em; text-transform: uppercase;
|
|
|
|
|
}
|
|
|
|
|
.bar-track { height: 6px; background: var(--surface2); border-radius: 99px; overflow: hidden; }
|
|
|
|
|
.bar-fill {
|
|
|
|
|
height: 100%;
|
|
|
|
|
background: color-mix(in srgb, var(--status-color) 70%, var(--surface2));
|
|
|
|
|
border-radius: 99px;
|
|
|
|
|
transition: width 0.8s cubic-bezier(.4,0,.2,1), background 0.6s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.audio-card {
|
|
|
|
|
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
|
|
|
|
padding: 1.25rem 2rem; display: flex; align-items: center;
|
|
|
|
|
justify-content: space-between; gap: 1rem; flex-wrap: wrap;
|
|
|
|
|
}
|
|
|
|
|
.audio-info { display: flex; align-items: center; gap: 0.75rem; }
|
|
|
|
|
.audio-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim); transition: background 0.3s; }
|
|
|
|
|
.audio-dot.active { background: var(--c-scanning); box-shadow: 0 0 6px var(--c-scanning); }
|
|
|
|
|
.audio-text { font-size: 0.85rem; color: var(--text-dim); }
|
|
|
|
|
.audio-controls { display: flex; gap: 0.75rem; }
|
|
|
|
|
|
|
|
|
|
button {
|
|
|
|
|
font-family: var(--mono); font-size: 0.7rem; font-weight: 700;
|
|
|
|
|
letter-spacing: 0.1em; text-transform: uppercase;
|
|
|
|
|
border: 1px solid var(--border); background: var(--surface2); color: var(--text);
|
|
|
|
|
padding: 0.4rem 1rem; border-radius: 6px; cursor: pointer;
|
|
|
|
|
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
|
|
|
|
}
|
|
|
|
|
button:hover { background: var(--border); }
|
|
|
|
|
button.active { background: var(--status-color); border-color: var(--status-color); color: var(--bg); }
|
|
|
|
|
|
|
|
|
|
footer {
|
|
|
|
|
font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim);
|
|
|
|
|
border-top: 1px solid var(--border); padding-top: 1rem;
|
|
|
|
|
display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#audio-gate {
|
|
|
|
|
position: fixed; inset: 0; background: rgba(13,15,20,0.85);
|
|
|
|
|
backdrop-filter: blur(6px); display: flex; align-items: center;
|
|
|
|
|
justify-content: center; z-index: 100; cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
#audio-gate.hidden { display: none; }
|
|
|
|
|
.gate-box {
|
|
|
|
|
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
|
|
|
|
padding: 2.5rem 3rem; text-align: center; max-width: 400px;
|
|
|
|
|
}
|
|
|
|
|
.gate-box h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem; }
|
|
|
|
|
.gate-box p { font-size: 0.85rem; color: var(--text-dim); margin-bottom: 1.5rem; }
|
|
|
|
|
.gate-btn {
|
|
|
|
|
font-family: var(--mono); font-size: 0.8rem; font-weight: 700;
|
|
|
|
|
letter-spacing: 0.1em; text-transform: uppercase;
|
|
|
|
|
background: var(--status-color); color: var(--bg); border: none;
|
|
|
|
|
padding: 0.7rem 2rem; border-radius: 8px; cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body class="unknown">
|
|
|
|
|
|
|
|
|
|
<div id="audio-gate">
|
|
|
|
|
<div class="gate-box">
|
|
|
|
|
<h2>flOMNI Status Page</h2>
|
|
|
|
|
<p>Click to enable the page. Audio warnings require a user interaction to activate.</p>
|
|
|
|
|
<button class="gate-btn" onclick="unlockAudio()">Open Status Page</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="layout">
|
|
|
|
|
|
|
|
|
|
<header>
|
|
|
|
|
<div class="logo">fl<span>OMNY</span> · STATUS</div>
|
|
|
|
|
<div id="last-update">updating…</div>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<div class="status-card">
|
|
|
|
|
<div class="status-pill" id="status-pill">-</div>
|
|
|
|
|
<div class="status-detail" id="status-detail">Loading…</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="progress-card">
|
|
|
|
|
<div class="rings-wrap">
|
|
|
|
|
<svg viewBox="0 0 120 120">
|
|
|
|
|
<circle class="ring-track" cx="60" cy="60" r="50" stroke-width="8"/>
|
|
|
|
|
<circle class="ring-outer" id="ring-outer"
|
|
|
|
|
cx="60" cy="60" r="50" stroke-width="8"
|
|
|
|
|
stroke-dasharray="314.16" stroke-dashoffset="314.16"/>
|
|
|
|
|
<circle class="ring-track" cx="60" cy="60" r="37" stroke-width="7"/>
|
|
|
|
|
<circle class="ring-inner" id="ring-inner"
|
|
|
|
|
cx="60" cy="60" r="37" stroke-width="7"
|
|
|
|
|
stroke-dasharray="232.48" stroke-dashoffset="232.48"/>
|
|
|
|
|
</svg>
|
|
|
|
|
<div class="ring-label">
|
|
|
|
|
<span class="pct" id="ring-pct">0%</span>
|
|
|
|
|
<span class="sublbl">OVERALL</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="progress-info">
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
<span class="label">Projection</span>
|
|
|
|
|
<span class="value" id="pi-proj">-</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
<span class="label">Sub-tomo</span>
|
|
|
|
|
<span class="value" id="pi-subtomo">-</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
<span class="label">Angle</span>
|
|
|
|
|
<span class="value" id="pi-angle">-</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
<span class="label">Tomo type</span>
|
|
|
|
|
<span class="value" id="pi-type">-</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
<span class="label">ETA</span>
|
|
|
|
|
<span class="value" id="pi-eta">-</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
<span class="label">Started</span>
|
|
|
|
|
<span class="value" id="pi-start">-</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="bar-wrap">
|
|
|
|
|
<div class="bar-label">
|
|
|
|
|
<span>Sub-tomo progress</span>
|
|
|
|
|
<span id="bar-sub-label">-</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="bar-track">
|
|
|
|
|
<div class="bar-fill" id="bar-sub-fill" style="width:0%"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="audio-card">
|
|
|
|
|
<div class="audio-info">
|
|
|
|
|
<div class="audio-dot" id="audio-dot"></div>
|
|
|
|
|
<span class="audio-text" id="audio-text">Audio warnings: initialising…</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="audio-controls">
|
|
|
|
|
<button id="btn-toggle-audio" onclick="toggleAudio()">Enable</button>
|
|
|
|
|
<button onclick="testSound()">Test sound</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<footer>
|
|
|
|
|
<span id="footer-gen">generator: -</span>
|
|
|
|
|
<span id="footer-queue">queue active: -</span>
|
|
|
|
|
<span id="footer-hb">heartbeat: -</span>
|
|
|
|
|
</footer>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
const STATUS_JSON = 'status.json';
|
|
|
|
|
const POLL_INTERVAL = 15000;
|
|
|
|
|
const WARN_STATUSES = new Set(['idle_long', 'error', 'unknown']);
|
|
|
|
|
|
|
|
|
|
// Audio
|
|
|
|
|
let audioCtx = null;
|
|
|
|
|
let audioEnabled = (localStorage.getItem('audioEnabled') !== 'false');
|
|
|
|
|
let warningTimer = null;
|
|
|
|
|
|
|
|
|
|
function getCtx() {
|
|
|
|
|
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
|
|
|
return audioCtx;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function beep(freq, dur, vol) {
|
|
|
|
|
try {
|
|
|
|
|
const ctx = getCtx(), osc = ctx.createOscillator(), gain = ctx.createGain();
|
|
|
|
|
osc.connect(gain); gain.connect(ctx.destination);
|
|
|
|
|
osc.type = 'sine';
|
|
|
|
|
osc.frequency.setValueAtTime(freq, ctx.currentTime);
|
|
|
|
|
gain.gain.setValueAtTime(vol, ctx.currentTime);
|
|
|
|
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur);
|
|
|
|
|
osc.start(ctx.currentTime); osc.stop(ctx.currentTime + dur);
|
|
|
|
|
} catch(e) { console.warn('Audio:', e); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function warningChime() { beep(660, 0.3, 0.4); setTimeout(() => beep(440, 0.4, 0.4), 350); }
|
|
|
|
|
function testSound() { beep(880,0.15,0.4); setTimeout(()=>beep(1100,0.15,0.4),180); setTimeout(()=>beep(880,0.3,0.4),360); }
|
|
|
|
|
|
|
|
|
|
function toggleAudio() {
|
|
|
|
|
audioEnabled = !audioEnabled;
|
|
|
|
|
localStorage.setItem('audioEnabled', audioEnabled);
|
|
|
|
|
updateAudioUI();
|
|
|
|
|
if (!audioEnabled) stopWarning();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateAudioUI() {
|
|
|
|
|
const btn = document.getElementById('btn-toggle-audio');
|
|
|
|
|
const dot = document.getElementById('audio-dot');
|
|
|
|
|
const txt = document.getElementById('audio-text');
|
|
|
|
|
if (audioEnabled) {
|
|
|
|
|
btn.textContent = 'Disable'; btn.classList.add('active');
|
|
|
|
|
dot.classList.add('active'); txt.textContent = 'Audio warnings: enabled';
|
|
|
|
|
} else {
|
|
|
|
|
btn.textContent = 'Enable'; btn.classList.remove('active');
|
|
|
|
|
dot.classList.remove('active'); txt.textContent = 'Audio warnings: disabled';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startWarning() {
|
|
|
|
|
if (warningTimer) return;
|
|
|
|
|
if (audioEnabled) warningChime();
|
|
|
|
|
warningTimer = setInterval(() => { if (audioEnabled) warningChime(); }, 30000);
|
|
|
|
|
}
|
|
|
|
|
function stopWarning() { if (warningTimer) { clearInterval(warningTimer); warningTimer = null; } }
|
|
|
|
|
|
|
|
|
|
function unlockAudio() {
|
|
|
|
|
getCtx().resume();
|
|
|
|
|
document.getElementById('audio-gate').classList.add('hidden');
|
|
|
|
|
updateAudioUI();
|
|
|
|
|
poll();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Rendering
|
|
|
|
|
const LABELS = {
|
|
|
|
|
scanning: 'SCANNING',
|
|
|
|
|
running: 'RUNNING',
|
|
|
|
|
idle_short: 'IDLE',
|
|
|
|
|
idle_long: 'IDLE - CHECK',
|
|
|
|
|
error: 'STOPPED',
|
|
|
|
|
unknown: 'UNKNOWN',
|
|
|
|
|
};
|
|
|
|
|
const DETAILS = {
|
|
|
|
|
scanning: d => 'Tomo scan in progress · projection ' + d.progress.projection + ' of ' + d.progress.total_projections + ' · ' + d.progress.tomo_type,
|
|
|
|
|
running: d => 'Queue active · outside tomo heartbeat window (alignment or inter-scan gap)',
|
|
|
|
|
idle_short: d => 'Finished normally · idle for <strong>' + d.idle_for_human + '</strong>',
|
|
|
|
|
idle_long: d => 'Idle for <strong>' + d.idle_for_human + '</strong> — no tomo scan running',
|
|
|
|
|
error: d => 'Queue stopped unexpectedly · idle for <strong>' + (d.idle_for_human || '?') + '</strong>',
|
|
|
|
|
unknown: d => 'Status unknown · waiting for first data…',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function setRing(id, circ, pct) {
|
|
|
|
|
document.getElementById(id).style.strokeDashoffset = circ * (1 - Math.min(pct, 1));
|
|
|
|
|
}
|
|
|
|
|
function fmtAngle(v) { const n = parseFloat(v); return isNaN(n) ? '-' : n.toFixed(2) + '\u00b0'; }
|
|
|
|
|
function fmtTime(iso) {
|
|
|
|
|
if (!iso) return '-';
|
|
|
|
|
try { return new Date(iso).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}); }
|
|
|
|
|
catch { return iso; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function render(d) {
|
|
|
|
|
const s = d.experiment_status || 'unknown';
|
|
|
|
|
const p = d.progress || {};
|
|
|
|
|
|
|
|
|
|
document.body.className = s;
|
|
|
|
|
document.getElementById('status-pill').textContent = LABELS[s] || s.toUpperCase();
|
|
|
|
|
document.getElementById('status-detail').innerHTML = (DETAILS[s] || (()=>s))(d);
|
|
|
|
|
|
|
|
|
|
const oPct = p.total_projections > 0 ? p.projection / p.total_projections : 0;
|
|
|
|
|
const sPct = p.subtomo_total_projections > 0 ? p.subtomo_projection / p.subtomo_total_projections : 0;
|
|
|
|
|
setRing('ring-outer', 314.16, oPct);
|
|
|
|
|
setRing('ring-inner', 232.48, sPct);
|
|
|
|
|
document.getElementById('ring-pct').textContent = Math.round(oPct * 100) + '%';
|
|
|
|
|
|
|
|
|
|
document.getElementById('pi-proj').textContent = (p.projection || 0) + ' / ' + (p.total_projections || 0);
|
|
|
|
|
document.getElementById('pi-subtomo').textContent = p.subtomo || '-';
|
|
|
|
|
document.getElementById('pi-angle').textContent = fmtAngle(p.angle);
|
|
|
|
|
document.getElementById('pi-type').textContent = p.tomo_type || '-';
|
|
|
|
|
document.getElementById('pi-eta').textContent = p.estimated_remaining_human || '-';
|
|
|
|
|
document.getElementById('pi-start').textContent = fmtTime(p.tomo_start_time);
|
|
|
|
|
|
|
|
|
|
document.getElementById('bar-sub-label').textContent = (p.subtomo_projection || 0) + ' / ' + (p.subtomo_total_projections || 0);
|
|
|
|
|
document.getElementById('bar-sub-fill').style.width = (sPct * 100).toFixed(1) + '%';
|
|
|
|
|
|
|
|
|
|
document.getElementById('last-update').textContent = 'updated ' + new Date(d.generated_at).toLocaleTimeString();
|
|
|
|
|
document.getElementById('footer-gen').textContent = 'generator: ' + (d.generator && d.generator.owner_id || '-');
|
|
|
|
|
document.getElementById('footer-queue').textContent = 'queue active: ' + d.queue_has_active_scan;
|
|
|
|
|
document.getElementById('footer-hb').textContent = 'heartbeat: ' + (p.heartbeat_age_s != null ? p.heartbeat_age_s + 's ago' : 'none');
|
|
|
|
|
|
|
|
|
|
WARN_STATUSES.has(s) ? startWarning() : stopWarning();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function poll() {
|
|
|
|
|
try {
|
|
|
|
|
const r = await fetch(STATUS_JSON + '?t=' + Date.now());
|
|
|
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
|
|
|
render(await r.json());
|
|
|
|
|
} catch(e) {
|
|
|
|
|
console.warn('Fetch failed:', e);
|
|
|
|
|
document.getElementById('last-update').textContent = 'fetch failed - retrying...';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateAudioUI();
|
|
|
|
|
setInterval(poll, POLL_INTERVAL);
|
|
|
|
|
// First poll triggered by unlockAudio() after the gate is dismissed
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
"""
|