Add scan number display and beamline states diagnostic card
- Append the current BEC scan number to the Projection info-item, e.g. "120 / 400 (S06650→S06770)", for direct comparison against ptycho reconstruction filenames (which use the same S##### scan number convention). Read from primary.info[0].active_request_block.scan_number — already available on the queue object, no new global var needed. - Add tomo_start_scan_number to the progress payload, mirroring the existing tomo_start_time pattern: a new tomo_progress key to be written once by the scan loop when a tomogram begins. Producer side (flomni.py) not yet implemented — falls back to showing only the current scan number until then. - New "Beamline states" draggable card (positioned just above Contacts): lists every beamline state currently registered with BEC, its live status (valid/invalid/warning/unknown), and whether the scan interlock is watching it. Cross-references bec.builtin_actors.scan_interlock.states_watched/.enabled against each state's live status to flag which watched states are currently mismatched (i.e. actually contributing to a queue lock), vs. states that are invalid but not watched and therefore not blocking anything (e.g. a simulated/test shutter). This is a display-only diagnostic; the "blocked" experiment_status itself is unchanged and still derived from the queue's own locks (_read_queue_locks), not from beamline states directly. - Add fmtScan() JS helper (S##### zero-padded formatting, matching the existing ptycho filename convention) and renderBeamlineStates(). - Help panel updated to explain both additions.
This commit is contained in:
@@ -179,6 +179,102 @@ def _gvar(bec_client, key, fmt=None, suffix=""):
|
||||
except Exception:
|
||||
return str(val)
|
||||
|
||||
def _read_beamline_states_info(bec_client) -> dict:
|
||||
"""Return a diagnostic snapshot of all configured beamline states, for
|
||||
display only (NOT used to derive the 'blocked' experiment status — that
|
||||
is computed from the queue's own locks via _read_queue_locks(), which is
|
||||
the authoritative source; see its docstring for why).
|
||||
|
||||
Returns:
|
||||
{
|
||||
"enabled": bool | None, # bec.builtin_actors.scan_interlock.enabled
|
||||
"states": [
|
||||
{
|
||||
"name": str,
|
||||
"status": str, # 'valid' | 'invalid' | 'warning' | 'unknown'
|
||||
"label": str,
|
||||
"watched": bool, # True if present in states_watched
|
||||
"accepted": list[str] | None, # accepted statuses if watched
|
||||
"mismatched": bool, # True if watched AND status not in accepted
|
||||
},
|
||||
...
|
||||
],
|
||||
}
|
||||
|
||||
Beamline states are registered dynamically and can differ from
|
||||
experiment to experiment, so names are read from the manager's _states
|
||||
dict (the same one show_all() iterates over internally — there is no
|
||||
public "list names" method as of this BEC version). states_watched and
|
||||
enabled are read through the public scan_interlock HLI properties.
|
||||
"""
|
||||
manager = getattr(bec_client, "beamline_states", None)
|
||||
if manager is None:
|
||||
return {"enabled": None, "states": []}
|
||||
|
||||
try:
|
||||
names = list(getattr(manager, "_states", {}).keys())
|
||||
except Exception:
|
||||
names = []
|
||||
|
||||
try:
|
||||
interlock = bec_client.builtin_actors.scan_interlock
|
||||
enabled = interlock.enabled
|
||||
states_watched = interlock.states_watched or {}
|
||||
except Exception:
|
||||
enabled = None
|
||||
states_watched = {}
|
||||
|
||||
states = []
|
||||
for name in names:
|
||||
try:
|
||||
state_obj = getattr(manager, name, None)
|
||||
if state_obj is None:
|
||||
continue
|
||||
info = state_obj.get() # {"status": ..., "label": ...}
|
||||
status = info.get("status", "unknown")
|
||||
label = info.get("label", name)
|
||||
except Exception:
|
||||
status, label = "unknown", name
|
||||
|
||||
accepted = states_watched.get(name)
|
||||
watched = accepted is not None
|
||||
mismatched = watched and status not in accepted
|
||||
|
||||
states.append({
|
||||
"name": name,
|
||||
"status": status,
|
||||
"label": label,
|
||||
"watched": watched,
|
||||
"accepted": accepted,
|
||||
"mismatched": mismatched,
|
||||
})
|
||||
|
||||
return {"enabled": enabled, "states": states}
|
||||
|
||||
|
||||
def _read_queue_locks(primary) -> list:
|
||||
"""Return a list of {"identifier": str, "reason": str} for every lock
|
||||
currently applied to the primary scan queue.
|
||||
|
||||
BEC's scan_queue.status becomes "LOCKED" (saving the prior status to
|
||||
restore later) whenever any lock is added, e.g. by the ScanInterlockActor
|
||||
when a watched beamline state goes out of spec (see scan_interlock.py:
|
||||
add_queue_lock(queue="primary", reason=..., lock_id="ScanInterlockActor")).
|
||||
Reading the lock list directly is more robust than re-deriving "blocked"
|
||||
from beamline states ourselves: it reflects whatever is actually holding
|
||||
the queue, regardless of which actor (interlock or otherwise) caused it,
|
||||
and survives changes to which beamline states are configured/watched.
|
||||
"""
|
||||
if primary is None:
|
||||
return []
|
||||
try:
|
||||
return [
|
||||
{"identifier": lock.identifier, "reason": lock.reason}
|
||||
for lock in (primary.locks or [])
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status derivation
|
||||
@@ -189,19 +285,27 @@ def _derive_status(
|
||||
queue_has_active_scan: bool,
|
||||
last_active_time,
|
||||
had_activity: bool,
|
||||
queue_locks: list | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Returns one of:
|
||||
scanning -- tomo heartbeat fresh (< _TOMO_HEARTBEAT_STALE_S), OR
|
||||
heartbeat recently seen AND queue still has active scan
|
||||
(handles long individual projections > heartbeat timeout)
|
||||
running -- queue has active scan but heartbeat has never been seen
|
||||
blocked -- queue has active scan, heartbeat stale (beyond the long-
|
||||
projection grace window), AND the primary queue has at
|
||||
least one lock applied (e.g. by BEC's ScanInterlockActor
|
||||
when a watched beamline state goes out of spec). Only
|
||||
reported when it is actually preventing a queued/active
|
||||
scan from progressing.
|
||||
running -- queue has active scan but heartbeat has never been seen,
|
||||
and the queue is not locked
|
||||
idle -- not scanning, last_active_time known
|
||||
unknown -- no activity ever seen since generator started
|
||||
|
||||
'unknown' is ONLY returned before any scan activity has been observed.
|
||||
Once activity has been seen the status goes directly:
|
||||
scanning/running -> idle
|
||||
scanning/running/blocked -> idle
|
||||
never through 'unknown'.
|
||||
"""
|
||||
hb_age = _heartbeat_age_s(progress.get("heartbeat"))
|
||||
@@ -213,6 +317,8 @@ def _derive_status(
|
||||
if queue_has_active_scan and hb_age < _TOMO_HEARTBEAT_STALE_S * 10:
|
||||
return "scanning"
|
||||
if queue_has_active_scan:
|
||||
if queue_locks:
|
||||
return "blocked"
|
||||
return "running"
|
||||
if last_active_time is not None or had_activity:
|
||||
return "idle"
|
||||
@@ -852,7 +958,10 @@ class WebpageGeneratorBase:
|
||||
progress = self._bec.get_global_var("tomo_progress") or {}
|
||||
|
||||
# ── Queue ────────────────────────────────────────────────────
|
||||
# queue_status is always 'RUNNING' while BEC is alive.
|
||||
# queue_status reflects BEC's ScanQueueStatus: 'RUNNING', 'PAUSED',
|
||||
# or 'LOCKED' (the latter set whenever any lock — e.g. from
|
||||
# ScanInterlockActor — is applied to the queue; the prior status is
|
||||
# restored once the lock is released).
|
||||
# A scan is actually executing only when info is non-empty AND
|
||||
# active_request_block is set on the first entry.
|
||||
try:
|
||||
@@ -864,9 +973,30 @@ class WebpageGeneratorBase:
|
||||
and len(primary.info) > 0
|
||||
and primary.info[0].active_request_block is not None
|
||||
)
|
||||
queue_locks = _read_queue_locks(primary)
|
||||
except Exception:
|
||||
queue_status = "unknown"
|
||||
queue_has_active_scan = False
|
||||
queue_locks = []
|
||||
|
||||
# ── Beamline states (diagnostic display only; not used for the
|
||||
# 'blocked' experiment_status decision — see _read_queue_locks) ──
|
||||
try:
|
||||
beamline_states_info = _read_beamline_states_info(self._bec)
|
||||
except Exception as exc:
|
||||
self._log(VERBOSITY_VERBOSE,
|
||||
f"beamline_states read error: {exc}", level="warning")
|
||||
beamline_states_info = {"enabled": None, "states": []}
|
||||
|
||||
# Current scan number (the BEC scan in progress right now, e.g. for
|
||||
# comparison against ptycho reconstruction filenames like S06770).
|
||||
# Only meaningful while a scan is actually active; None otherwise.
|
||||
current_scan_number = None
|
||||
if queue_has_active_scan:
|
||||
try:
|
||||
current_scan_number = primary.info[0].active_request_block.scan_number
|
||||
except Exception:
|
||||
current_scan_number = None
|
||||
|
||||
# ── Idle tracking ────────────────────────────────────────────
|
||||
hb_age = _heartbeat_age_s(progress.get("heartbeat"))
|
||||
@@ -893,6 +1023,7 @@ class WebpageGeneratorBase:
|
||||
exp_status = _derive_status(
|
||||
progress, queue_has_active_scan,
|
||||
self._last_active_time, self._had_activity,
|
||||
queue_locks,
|
||||
)
|
||||
idle_for_s = (
|
||||
None if self._last_active_time is None
|
||||
@@ -928,6 +1059,8 @@ class WebpageGeneratorBase:
|
||||
"queue_has_active_scan": queue_has_active_scan,
|
||||
"idle_for_s": idle_for_s,
|
||||
"idle_for_human": _format_duration(idle_for_s),
|
||||
"queue_locks": queue_locks,
|
||||
"beamline_states": beamline_states_info,
|
||||
"progress": {
|
||||
"tomo_type": progress.get("tomo_type", "N/A"),
|
||||
"projection": progress.get("projection", 0),
|
||||
@@ -937,10 +1070,13 @@ class WebpageGeneratorBase:
|
||||
"subtomo_total_projections": progress.get("subtomo_total_projections", 1),
|
||||
"angle": progress.get("angle", 0),
|
||||
"tomo_start_time": progress.get("tomo_start_time"),
|
||||
"tomo_start_scan_number": progress.get("tomo_start_scan_number"),
|
||||
"current_scan_number": current_scan_number,
|
||||
"estimated_remaining_s": progress.get("estimated_remaining_time"),
|
||||
"estimated_remaining_human": _format_duration(
|
||||
progress.get("estimated_remaining_time")
|
||||
),
|
||||
"estimated_finish_time": progress.get("estimated_finish_time"),
|
||||
"tomo_heartbeat": progress.get("heartbeat"),
|
||||
"tomo_heartbeat_age_s": round(hb_age, 1) if hb_age != float("inf") else None,
|
||||
},
|
||||
@@ -985,12 +1121,16 @@ class WebpageGeneratorBase:
|
||||
self._uploader.upload_changed_async(self._output_dir)
|
||||
|
||||
# ── Console feedback ──────────────────────────────────────────
|
||||
blocked_summary = (
|
||||
f" locked_by={','.join(l['identifier'] for l in queue_locks)}"
|
||||
if queue_locks else ""
|
||||
)
|
||||
self._log(VERBOSITY_VERBOSE,
|
||||
f"[{_now_iso()}] {exp_status:<12} active={queue_has_active_scan} "
|
||||
f"proj={payload['progress']['projection']}/"
|
||||
f"{payload['progress']['total_projections']} "
|
||||
f"hb={payload['progress']['tomo_heartbeat_age_s']}s "
|
||||
f"idle={_format_duration(idle_for_s)}")
|
||||
f"idle={_format_duration(idle_for_s)}" + blocked_summary)
|
||||
self._log(VERBOSITY_DEBUG,
|
||||
f" payload:\n{json.dumps(payload, indent=4, default=str)}")
|
||||
|
||||
@@ -1331,6 +1471,8 @@ def _render_html(phone_numbers: list) -> str:
|
||||
--c-scanning: #89dceb;
|
||||
--c-running: #a6e3a1;
|
||||
--c-idle-short: #f9e2af;
|
||||
--c-idle-long: #f38ba8;
|
||||
--c-blocked: #fab387;
|
||||
--c-error: #f38ba8;
|
||||
--status-color: #6c7a9c;
|
||||
--ring-blend: #4a5568;
|
||||
@@ -1373,6 +1515,7 @@ def _render_html(phone_numbers: list) -> str:
|
||||
body.scanning {{ --status-color: var(--c-scanning); --ring-blend: #3a6b74; }}
|
||||
body.running {{ --status-color: var(--c-running); --ring-blend: #3a6644; }}
|
||||
body.idle {{ --status-color: var(--c-idle-short); --ring-blend: #7a6e44; }}
|
||||
body.blocked {{ --status-color: var(--c-blocked); --ring-blend: #7a5a3a; }}
|
||||
body.error {{ --status-color: var(--c-error); --ring-blend: #7a3a44; }}
|
||||
body.unknown {{ --status-color: var(--text-dim); --ring-blend: #4a5568; }}
|
||||
|
||||
@@ -1585,6 +1728,36 @@ def _render_html(phone_numbers: list) -> str:
|
||||
border-radius: 4px; box-shadow: 0 8px 40px rgba(0,0,0,0.6);
|
||||
}}
|
||||
|
||||
/* Help dialog */
|
||||
#help-dialog {{
|
||||
background: var(--surface); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 1.5rem 1.75rem; max-width: 560px; width: 90vw;
|
||||
max-height: 80vh; overflow-y: auto;
|
||||
font-family: var(--sans); font-weight: 300;
|
||||
}}
|
||||
#help-dialog::backdrop {{ background: rgba(0,0,0,0.6); }}
|
||||
.help-header {{
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border); padding-bottom: 0.75rem; margin-bottom: 1rem;
|
||||
}}
|
||||
.help-close {{
|
||||
background: none; border: none; color: var(--text-dim);
|
||||
font-size: 1.4rem; line-height: 1; padding: 0 0.3rem; min-height: auto;
|
||||
cursor: pointer;
|
||||
}}
|
||||
.help-close:hover {{ color: var(--text); background: none; }}
|
||||
.help-body h3 {{
|
||||
font-family: var(--mono); font-size: 0.7rem; font-weight: 700;
|
||||
letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-dim);
|
||||
margin: 1.1rem 0 0.5rem;
|
||||
}}
|
||||
.help-body h3:first-child {{ margin-top: 0; }}
|
||||
.help-body p {{ font-size: 0.85rem; line-height: 1.6; color: var(--text-dim); margin-bottom: 0.5rem; }}
|
||||
.help-body ul {{ margin: 0 0 0.5rem 1.1rem; padding: 0; }}
|
||||
.help-body li {{ font-size: 0.85rem; line-height: 1.6; color: var(--text-dim); margin-bottom: 0.4rem; }}
|
||||
.help-body strong {{ color: var(--text); font-weight: 600; }}
|
||||
|
||||
/* ── Instrument details ── */
|
||||
.instrument-grid {{
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem 2rem;
|
||||
@@ -1602,6 +1775,38 @@ def _render_html(phone_numbers: list) -> str:
|
||||
.kv-table td:last-child {{ color: var(--text); font-weight: 600; text-align: right; }}
|
||||
.temp-null {{ color: var(--text-dim) !important; font-weight: 400 !important; }}
|
||||
|
||||
/* ── Beamline states ── */
|
||||
.blstates-summary {{
|
||||
font-size: 0.82rem; color: var(--text-dim); margin-bottom: 0.9rem;
|
||||
}}
|
||||
.blstates-summary strong {{ color: var(--text); font-weight: 600; }}
|
||||
.blstates-table {{ width: 100%; border-collapse: collapse; }}
|
||||
.blstates-table th {{
|
||||
font-family: var(--mono); font-size: 0.6rem; font-weight: 700;
|
||||
letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-dim);
|
||||
text-align: left; padding: 0 0.6rem 0.5rem 0; border-bottom: 1px solid var(--border);
|
||||
}}
|
||||
.blstates-table td {{
|
||||
padding: 0.45rem 0.6rem 0.45rem 0; font-size: 0.82rem;
|
||||
border-bottom: 1px solid var(--border); vertical-align: middle;
|
||||
}}
|
||||
.blstates-table tr:last-child td {{ border-bottom: none; }}
|
||||
.blstates-table tr.mismatched td {{ color: var(--c-blocked); }}
|
||||
.bl-name {{ font-family: var(--mono); font-size: 0.78rem; color: var(--text); white-space: nowrap; }}
|
||||
.bl-label {{ color: var(--text-dim); }}
|
||||
.bl-badge {{
|
||||
display: inline-block; font-family: var(--mono); font-size: 0.6rem; font-weight: 700;
|
||||
letter-spacing: 0.06em; text-transform: uppercase; padding: 0.15rem 0.5rem;
|
||||
border-radius: 100px; white-space: nowrap;
|
||||
}}
|
||||
.bl-badge.bl-valid {{ background: color-mix(in srgb, var(--c-running) 22%, transparent); color: var(--c-running); }}
|
||||
.bl-badge.bl-invalid {{ background: color-mix(in srgb, var(--c-error) 22%, transparent); color: var(--c-error); }}
|
||||
.bl-badge.bl-warning {{ background: color-mix(in srgb, var(--c-idle-short) 25%, transparent); color: var(--c-idle-short); }}
|
||||
.bl-badge.bl-unknown {{ background: var(--surface2); color: var(--text-dim); }}
|
||||
.bl-badge.bl-watched-yes {{ background: color-mix(in srgb, var(--c-scanning) 18%, transparent); color: var(--c-scanning); }}
|
||||
.bl-badge.bl-watched-no {{ background: var(--surface2); color: var(--text-dim); }}
|
||||
.blstates-none {{ font-family: var(--mono); font-size: 0.75rem; color: var(--text-dim); padding: 0.5rem 0; }}
|
||||
|
||||
/* ── Audio card ── */
|
||||
.audio-card {{
|
||||
display: flex; align-items: center;
|
||||
@@ -1628,6 +1833,9 @@ def _render_html(phone_numbers: list) -> str:
|
||||
.led.led-warning {{ background: var(--c-idle-long); border-color: var(--c-idle-long);
|
||||
box-shadow: 0 0 8px var(--c-idle-long);
|
||||
animation: led-pulse 1s ease-in-out infinite; }}
|
||||
.led.led-blocked {{ background: var(--c-blocked); border-color: var(--c-blocked);
|
||||
box-shadow: 0 0 8px var(--c-blocked);
|
||||
animation: led-pulse 1s ease-in-out infinite; }}
|
||||
@keyframes led-pulse {{
|
||||
0%,100% {{ opacity: 1; }} 50% {{ opacity: 0.4; }}
|
||||
}}
|
||||
@@ -1716,6 +1924,7 @@ def _render_html(phone_numbers: list) -> str:
|
||||
<span class="logo-suffix">· STATUS</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="theme-btn" id="help-btn" onclick="openHelp()" title="What does this page show?">?</button>
|
||||
<div class="theme-switcher">
|
||||
<span class="ts-label">Theme</span>
|
||||
<button class="theme-btn" id="theme-auto" onclick="setTheme('auto')" >Auto</button>
|
||||
@@ -1753,7 +1962,8 @@ def _render_html(phone_numbers: list) -> str:
|
||||
<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">Remaining</span><span class="value" id="pi-remaining">-</span></div>
|
||||
<div class="info-item"><span class="label">Finish time</span><span class="value" id="pi-finish">-</span></div>
|
||||
<div class="info-item"><span class="label">Started</span><span class="value" id="pi-start">-</span></div>
|
||||
</div>
|
||||
|
||||
@@ -1765,6 +1975,39 @@ def _render_html(phone_numbers: list) -> str:
|
||||
<img id="ptycho-lightbox-img" src="" alt="">
|
||||
</div>
|
||||
|
||||
<!-- Help dialog -->
|
||||
<dialog id="help-dialog">
|
||||
<div class="help-header">
|
||||
<span class="card-title" style="border-bottom:none;margin-bottom:0;padding-bottom:0">About this page</span>
|
||||
<button class="help-close" onclick="closeHelp()" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="help-body">
|
||||
<h3>Measurement status</h3>
|
||||
<p>The coloured pill at the top reflects what the instrument is currently doing:</p>
|
||||
<ul>
|
||||
<li><strong>Scanning</strong> — a tomography scan is actively running (a heartbeat from the scan loop has been seen recently).</li>
|
||||
<li><strong>Running</strong> — the scan queue has an active item, but it is not a tomo scan with a heartbeat (e.g. an alignment scan).</li>
|
||||
<li><strong>Blocked</strong> — a scan is queued or active, but the scan queue itself has been locked (for example by BEC's scan interlock when a watched beamline condition, such as the shutter or ring current, is out of spec). This is usually external and self-resolves once the condition clears.</li>
|
||||
<li><strong>Idle</strong> — nothing is running or queued right now.</li>
|
||||
<li><strong>Unknown</strong> — shown only before any activity has been observed since the generator started.</li>
|
||||
</ul>
|
||||
<h3>Tomography progress</h3>
|
||||
<p>The rings show overall progress (outer) and progress within the current sub-tomogram (inner). <strong>Remaining</strong> is the estimated duration left; <strong>Finish time</strong> is the estimated wall-clock time the measurement will complete. The projection count is followed by the BEC scan number(s) in parentheses, e.g. (S06650→S06770), for direct comparison with reconstruction filenames.</p>
|
||||
<h3>Audio warnings</h3>
|
||||
<ul>
|
||||
<li><strong>System LED</strong> — on when audio is enabled on this device.</li>
|
||||
<li><strong>Watch LED</strong> — armed (green) while a scan is running; pulses orange if a scan stops unexpectedly, with a chime every 30s until confirmed.</li>
|
||||
<li><strong>Blocked LED</strong> — pulses orange while the queue is locked. A chime fires automatically after 60 continuous minutes blocked, then repeats hourly until cleared. No confirmation needed — it clears itself once the block resolves.</li>
|
||||
<li><strong>Live LED</strong> — green while this page's data feed is fresh; pulses orange if the feed goes stale or a fetch fails.</li>
|
||||
</ul>
|
||||
<p>On iPhone/iPad, tap <strong>Enable</strong> once to unlock audio — this is a browser requirement and only needs to be done once per visit.</p>
|
||||
<h3>Beamline states</h3>
|
||||
<p>Lists every beamline condition currently registered with BEC, its live status (valid/invalid/warning/unknown), and whether the scan interlock is <strong>watching</strong> it. A watched state that is not in its accepted status is what actually causes the <strong>Blocked</strong> status above — states that are not watched (like an unrelated simulated shutter) can be invalid without blocking anything.</p>
|
||||
<h3>Other features</h3>
|
||||
<p>Cards below the fixed status/progress section can be dragged by their ⋮⋮ handle to reorder; the order is remembered on this device. The theme switcher (top right) follows your OS by default, or can be forced to light/dark.</p>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Draggable cards container -->
|
||||
<div id="card-container">
|
||||
|
||||
@@ -1780,6 +2023,10 @@ def _render_html(phone_numbers: list) -> str:
|
||||
<div class="led" id="led-watch"></div>
|
||||
<span class="led-label">Watch</span>
|
||||
</div>
|
||||
<div class="led-group">
|
||||
<div class="led" id="led-blocked"></div>
|
||||
<span class="led-label">Blocked</span>
|
||||
</div>
|
||||
<div class="led-group">
|
||||
<div class="led" id="led-conn"></div>
|
||||
<span class="led-label">Live</span>
|
||||
@@ -1822,7 +2069,20 @@ def _render_html(phone_numbers: list) -> str:
|
||||
<div class="instrument-grid" id="instrument-grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Contacts -->
|
||||
<!-- 4. Beamline states -->
|
||||
<div class="card draggable-card" id="blstates-card" data-card-id="blstates">
|
||||
<div class="drag-handle" title="Drag to reorder">⋮⋮</div>
|
||||
<div class="card-title">Beamline states</div>
|
||||
<div class="blstates-summary" id="blstates-summary"></div>
|
||||
<table class="blstates-table" id="blstates-table">
|
||||
<thead>
|
||||
<tr><th>State</th><th>Status</th><th>Watched</th><th>Label</th></tr>
|
||||
</thead>
|
||||
<tbody id="blstates-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 5. Contacts -->
|
||||
<div class="card draggable-card" data-card-id="contacts">
|
||||
<div class="drag-handle" title="Drag to reorder">⋮⋮</div>
|
||||
<div class="card-title">Contacts</div>
|
||||
@@ -1860,7 +2120,7 @@ function setTheme(t) {{
|
||||
|
||||
// ── Drag-and-drop card ordering ──────────────────────────────────────────
|
||||
const CARD_ORDER_KEY = 'cardOrder';
|
||||
const DEFAULT_ORDER = ['audio','recon-queue','ptycho','instrument','contacts'];
|
||||
const DEFAULT_ORDER = ['audio','recon-queue','ptycho','instrument','blstates','contacts'];
|
||||
let _dragSrc = null;
|
||||
|
||||
function savedOrder() {{
|
||||
@@ -1932,6 +2192,9 @@ initDrag();
|
||||
let audioCtx=null, audioEnabled=false;
|
||||
let audioArmed=false, warningActive=false, warningTimer=null, lastStatus=null;
|
||||
let staleActive=false, staleTimer=null, staleConfirmed=false;
|
||||
let blockedSince=null, blockedWarningActive=false, blockedChimeTimer=null;
|
||||
const BLOCKED_WARNING_DELAY_MS=60*60*1000; // first chime after 60 min continuously blocked
|
||||
const BLOCKED_CHIME_REPEAT_MS=60*60*1000; // repeat every 60 min while still blocked
|
||||
|
||||
function getCtx(){{
|
||||
if(!audioCtx) audioCtx=new(window.AudioContext||window.webkitAudioContext)();
|
||||
@@ -1967,6 +2230,12 @@ function staleChime(){{
|
||||
setTimeout(()=>beep(1200,0.12,0.35),180);
|
||||
setTimeout(()=>beep(1200,0.25,0.35),360);
|
||||
}}
|
||||
function blockedChime(){{
|
||||
// Distinct from warningChime/staleChime: a slow low->low->high triple knock.
|
||||
beep(330,0.25,0.4);
|
||||
setTimeout(()=>beep(330,0.25,0.4),300);
|
||||
setTimeout(()=>beep(523,0.4, 0.4),600);
|
||||
}}
|
||||
|
||||
function testSound(){{
|
||||
// Gesture handler — unlock first, then delay beeps 80ms for resume().
|
||||
@@ -1986,8 +2255,10 @@ function toggleAudio(){{
|
||||
document.getElementById('btn-confirm').style.display='none';
|
||||
stopStaleWarning(); staleActive=false; staleConfirmed=false;
|
||||
document.getElementById('btn-confirm-stale').style.display='none';
|
||||
stopBlockedChime(); blockedWarningActive=false;
|
||||
}} else {{
|
||||
if(lastStatus==='scanning' && !audioArmed) audioArmed=true;
|
||||
if(blockedSince!==null && blockedChimeTimer===null) scheduleBlockedChime();
|
||||
}}
|
||||
updateAudioUI();
|
||||
}}
|
||||
@@ -2048,9 +2319,45 @@ function handleStale(isStale){{
|
||||
}}
|
||||
}}
|
||||
|
||||
function stopBlockedChime(){{
|
||||
if(blockedChimeTimer){{clearTimeout(blockedChimeTimer);blockedChimeTimer=null;}}
|
||||
}}
|
||||
|
||||
function scheduleBlockedChime(){{
|
||||
// Fires once after BLOCKED_WARNING_DELAY_MS of continuous 'blocked' status,
|
||||
// then repeats every BLOCKED_CHIME_REPEAT_MS for as long as it stays blocked.
|
||||
// No confirm button — purely informational, auto-clears on recovery, since
|
||||
// blocked states are external (beamline) and typically self-resolve.
|
||||
blockedChimeTimer=setTimeout(()=>{{
|
||||
if(audioEnabled) blockedChime();
|
||||
blockedWarningActive=true;
|
||||
updateAudioUI();
|
||||
scheduleBlockedChime();
|
||||
}}, blockedWarningActive ? BLOCKED_CHIME_REPEAT_MS : BLOCKED_WARNING_DELAY_MS);
|
||||
}}
|
||||
|
||||
function handleBlocked(isBlocked){{
|
||||
if(isBlocked){{
|
||||
if(blockedSince===null){{
|
||||
blockedSince=Date.now();
|
||||
blockedWarningActive=false;
|
||||
stopBlockedChime();
|
||||
if(audioEnabled) scheduleBlockedChime();
|
||||
}}
|
||||
}}else{{
|
||||
if(blockedSince!==null){{
|
||||
blockedSince=null;
|
||||
blockedWarningActive=false;
|
||||
stopBlockedChime();
|
||||
updateAudioUI();
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
function updateAudioUI(){{
|
||||
const ledSys=document.getElementById('led-system'),
|
||||
ledWatch=document.getElementById('led-watch'),
|
||||
ledBlocked=document.getElementById('led-blocked'),
|
||||
ledConn=document.getElementById('led-conn'),
|
||||
btn=document.getElementById('btn-toggle'),
|
||||
txt=document.getElementById('audio-text');
|
||||
@@ -2060,6 +2367,7 @@ function updateAudioUI(){{
|
||||
btn.classList.toggle('active',audioEnabled);
|
||||
|
||||
ledConn.className='led'+(staleActive?' led-warning':' led-live');
|
||||
ledBlocked.className='led'+(blockedSince!==null?' led-blocked':'');
|
||||
|
||||
const scanRunning=(lastStatus==='scanning');
|
||||
if(!audioEnabled){{
|
||||
@@ -2074,6 +2382,11 @@ function updateAudioUI(){{
|
||||
}}else if(staleActive){{
|
||||
ledWatch.className='led';
|
||||
txt.textContent='Live feed lost \u2014 confirm to silence';
|
||||
}}else if(blockedSince!==null){{
|
||||
ledWatch.className='led';
|
||||
txt.textContent=blockedWarningActive
|
||||
? 'Beamline blocked \u2014 chiming hourly until cleared'
|
||||
: 'Beamline blocked \u2014 will chime after 60 min if unresolved';
|
||||
}}else if(audioArmed && scanRunning){{
|
||||
ledWatch.className='led led-armed';
|
||||
txt.textContent='Armed \u2014 will warn when measurement stops';
|
||||
@@ -2107,6 +2420,17 @@ function handleAudioForStatus(status, prevStatus){{
|
||||
}}
|
||||
}}
|
||||
|
||||
// ── Help dialog ──────────────────────────────────────────────────────────
|
||||
function openHelp(){{
|
||||
document.getElementById('help-dialog').showModal();
|
||||
}}
|
||||
function closeHelp(){{
|
||||
document.getElementById('help-dialog').close();
|
||||
}}
|
||||
document.getElementById('help-dialog').addEventListener('click', e=>{{
|
||||
if(e.target.id==='help-dialog') closeHelp(); // click on backdrop area
|
||||
}});
|
||||
|
||||
// ── Ptychography images ────────────────────────────────────────────
|
||||
let _ptychoScanId = null;
|
||||
|
||||
@@ -2157,11 +2481,13 @@ function renderPtycho(ptycho){{
|
||||
}}
|
||||
|
||||
// ── Status rendering ──────────────────────────────────────────────────────
|
||||
const LABELS={{scanning:'SCANNING',running:'RUNNING',idle:'IDLE',error:'STOPPED',unknown:'UNKNOWN'}};
|
||||
const LABELS={{scanning:'SCANNING',running:'RUNNING',idle:'IDLE',blocked:'BLOCKED',error:'STOPPED',unknown:'UNKNOWN'}};
|
||||
const DETAILS={{
|
||||
scanning: d=>'Tomo scan in progress · projection '+(d.progress.projection||0)+' of '+(d.progress.total_projections||0)+' · '+(d.progress.tomo_type||''),
|
||||
running: d=>'Queue active · outside tomo heartbeat window',
|
||||
idle: d=>'Idle for <strong>'+d.idle_for_human+'</strong>',
|
||||
blocked: d=>'Queue locked'+((d.queue_locks&&d.queue_locks.length>1)?' by multiple locks':'')+': <strong>'
|
||||
+(d.queue_locks||[]).map(l=>esc(l.reason||l.identifier)).join('; ')+'</strong>',
|
||||
error: d=>'Queue stopped unexpectedly · idle for <strong>'+(d.idle_for_human||'?')+'</strong>',
|
||||
unknown: d=>'Waiting for first data\u2026',
|
||||
}};
|
||||
@@ -2169,6 +2495,12 @@ const DETAILS={{
|
||||
function setRing(id,circ,pct){{document.getElementById(id).style.strokeDashoffset=circ*(1-Math.min(Math.max(pct,0),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 fmtScan(n){{
|
||||
if(n==null) return null;
|
||||
const i=parseInt(n,10);
|
||||
if(isNaN(i)) return null;
|
||||
return 'S'+String(i).padStart(5,'0');
|
||||
}}
|
||||
function esc(s){{return String(s==null?'N/A':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
|
||||
|
||||
function renderInstrument(setup){{
|
||||
@@ -2192,6 +2524,39 @@ function renderInstrument(setup){{
|
||||
grid.innerHTML=html;
|
||||
}}
|
||||
|
||||
function renderBeamlineStates(bl){{
|
||||
const summary=document.getElementById('blstates-summary'),
|
||||
tbody=document.getElementById('blstates-tbody');
|
||||
const states=(bl&&bl.states)||[];
|
||||
const enabledTxt = bl&&bl.enabled===true ? 'enabled' : (bl&&bl.enabled===false ? 'disabled' : 'unknown');
|
||||
const mismatched = states.filter(s=>s.mismatched);
|
||||
|
||||
if(states.length===0){{
|
||||
summary.innerHTML='Scan interlock <strong>'+enabledTxt+'</strong> · no beamline states configured';
|
||||
tbody.innerHTML='<tr><td colspan="4"><div class="blstates-none">No beamline states configured</div></td></tr>';
|
||||
return;
|
||||
}}
|
||||
|
||||
summary.innerHTML = 'Scan interlock <strong>'+enabledTxt+'</strong>'
|
||||
+ (mismatched.length>0
|
||||
? ' · <strong>'+mismatched.length+'</strong> watched state'+(mismatched.length>1?'s':'')+' currently blocking'
|
||||
: ' · all watched states OK');
|
||||
|
||||
let html='';
|
||||
states.forEach(s=>{{
|
||||
const statusCls='bl-badge bl-'+(s.status||'unknown');
|
||||
const watchedCls='bl-badge '+(s.watched?'bl-watched-yes':'bl-watched-no');
|
||||
const rowCls=s.mismatched?' class="mismatched"':'';
|
||||
html+='<tr'+rowCls+'>'
|
||||
+'<td class="bl-name">'+esc(s.name)+'</td>'
|
||||
+'<td><span class="'+statusCls+'">'+esc(s.status)+'</span></td>'
|
||||
+'<td><span class="'+watchedCls+'">'+(s.watched?'watched':'not watched')+'</span></td>'
|
||||
+'<td class="bl-label">'+esc(s.label)+'</td>'
|
||||
+'</tr>';
|
||||
}});
|
||||
tbody.innerHTML=html;
|
||||
}}
|
||||
|
||||
function render(d){{
|
||||
const s=d.experiment_status||'unknown',p=d.progress||{{}};
|
||||
document.body.className=s;
|
||||
@@ -2201,11 +2566,17 @@ function render(d){{
|
||||
const sPct=p.subtomo_total_projections>0?p.subtomo_projection/p.subtomo_total_projections:0;
|
||||
setRing('ring-outer',289.03,oPct); setRing('ring-inner',213.63,sPct);
|
||||
document.getElementById('ring-pct').textContent=Math.round(oPct*100)+'%';
|
||||
document.getElementById('pi-proj').textContent=(p.projection||0)+' / '+(p.total_projections||0);
|
||||
let projText=(p.projection||0)+' / '+(p.total_projections||0);
|
||||
const startScan=fmtScan(p.tomo_start_scan_number), curScan=fmtScan(p.current_scan_number);
|
||||
if(startScan && curScan && startScan!==curScan) projText+=' ('+startScan+'\u2192'+curScan+')';
|
||||
else if(curScan) projText+=' ('+curScan+')';
|
||||
else if(startScan) projText+=' ('+startScan+')';
|
||||
document.getElementById('pi-proj').textContent=projText;
|
||||
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-remaining').textContent=p.estimated_remaining_human||'-';
|
||||
document.getElementById('pi-finish').textContent=fmtTime(p.estimated_finish_time);
|
||||
document.getElementById('pi-start').textContent=fmtTime(p.tomo_start_time);
|
||||
|
||||
if(d.recon){{
|
||||
@@ -2216,18 +2587,21 @@ function render(d){{
|
||||
document.getElementById('recon-path').textContent=d.recon.folder_path||'';
|
||||
}}
|
||||
renderInstrument(d.setup);
|
||||
renderBeamlineStates(d.beamline_states);
|
||||
renderPtycho(d.ptycho);
|
||||
document.getElementById('last-update').textContent='updated '+new Date(d.generated_at).toLocaleTimeString();
|
||||
const ageS=(Date.now()/1000)-d.generated_at_epoch;
|
||||
const isStale=ageS>STALE_S;
|
||||
document.getElementById('outdated-banner').classList.toggle('visible',isStale);
|
||||
handleStale(isStale);
|
||||
handleBlocked(s==='blocked');
|
||||
document.getElementById('footer-gen').textContent='generator: '+((d.generator||{{}}).owner_id||'-');
|
||||
document.getElementById('footer-hb').textContent='tomo_heartbeat: '+(p.tomo_heartbeat_age_s!=null?p.tomo_heartbeat_age_s+'s ago':'none');
|
||||
|
||||
const prevStatus=lastStatus;
|
||||
lastStatus=s;
|
||||
handleAudioForStatus(s, prevStatus);
|
||||
updateAudioUI();
|
||||
}}
|
||||
|
||||
let _fetchFailCount=0;
|
||||
|
||||
Reference in New Issue
Block a user