first version of webpage
All checks were successful
CI for csaxs_bec / test (push) Successful in 1m56s

This commit is contained in:
x12sa
2026-03-24 17:06:08 +01:00
parent 88df4781ec
commit 9e84b8c510
2 changed files with 909 additions and 3 deletions

View File

@@ -21,6 +21,14 @@ from csaxs_bec.bec_ipython_client.plugins.omny.omny_general_tools import (
TomoIDManager,
)
from csaxs_bec.bec_ipython_client.plugins.flomni.webpage_generator import (
WebpageGenerator,
VERBOSITY_SILENT, # 0 — no output
VERBOSITY_NORMAL, # 1 — startup / stop messages only (default)
VERBOSITY_VERBOSE, # 2 — one-line summary per cycle
VERBOSITY_DEBUG, # 3 — full JSON payload per cycle
)
logger = bec_logger.logger
if builtins.__dict__.get("bec") is not None:
@@ -1303,6 +1311,12 @@ class Flomni(
self.corr_angle_y_2 = []
self._progress_proxy = _ProgressProxy(self.client)
self._progress_proxy.reset()
self._webpage_gen = WebpageGenerator(
bec_client=client,
output_dir="~/data/raw/webpage/", # adjust to your staging path
verbosity=VERBOSITY_NORMAL,
)
self._webpage_gen.start()
self.OMNYTools = OMNYTools(self.client)
self.reconstructor = PtychoReconstructor(self.ptycho_reconstruct_foldername)
self.tomo_id_manager = TomoIDManager()
@@ -2249,8 +2263,8 @@ class Flomni(
+ ' 888 888 "Y88888P" 888 888 888 Y888 8888888 \n'
)
padding = 20
fovxy = f"{self.fovx:.2f}/{self.fovy:.2f}"
stitching = f"{self.stitch_x:.2f}/{self.stitch_y:.2f}"
fovxy = f"{self.fovx:.1f}/{self.fovy:.1f}"
stitching = f"{self.stitch_x:.0f}/{self.stitch_y:.0f}"
dataset_id = str(self.client.queue.next_dataset_number)
account = bec.active_account
content = [
@@ -2267,7 +2281,7 @@ class Flomni(
f"{'Exposure time:':<{padding}}{self.tomo_countingtime:>{padding}.2f}\n",
f"{'Fermat spiral step size:':<{padding}}{self.tomo_shellstep:>{padding}.2f}\n",
f"{'FOV:':<{padding}}{fovxy:>{padding}}\n",
f"{'Stitching:':<{padding}}{stitching:>{padding}.0f}\n",
f"{'Stitching:':<{padding}}{stitching:>{padding}}\n",
f"{'Number of individual sub-tomograms:':<{padding}}{8:>{padding}}\n",
f"{'Angular step within sub-tomogram:':<{padding}}{self.tomo_angle_stepsize:>{padding}.2f}\n",
]

View File

@@ -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> &middot; STATUS</div>
<div id="last-update">updating&hellip;</div>
</header>
<div class="status-card">
<div class="status-pill" id="status-pill">-</div>
<div class="status-detail" id="status-detail">Loading&hellip;</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&hellip;</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 &middot; projection ' + d.progress.projection + ' of ' + d.progress.total_projections + ' &middot; ' + d.progress.tomo_type,
running: d => 'Queue active &middot; outside tomo heartbeat window (alignment or inter-scan gap)',
idle_short: d => 'Finished normally &middot; idle for <strong>' + d.idle_for_human + '</strong>',
idle_long: d => 'Idle for <strong>' + d.idle_for_human + '</strong> &mdash; no tomo scan running',
error: d => 'Queue stopped unexpectedly &middot; idle for <strong>' + (d.idle_for_human || '?') + '</strong>',
unknown: d => 'Status unknown &middot; waiting for first data&hellip;',
};
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>
"""