diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py index 6ea3b10..e6694d1 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py @@ -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: · STATUS
+
Theme @@ -1753,7 +1962,8 @@ def _render_html(phone_numbers: list) -> str:
Sub-tomo-
Angle-
Tomo type-
-
ETA-
+
Remaining-
+
Finish time-
Started-
@@ -1765,6 +1975,39 @@ def _render_html(phone_numbers: list) -> str:
+ + +
+ About this page + +
+
+

Measurement status

+

The coloured pill at the top reflects what the instrument is currently doing:

+ +

Tomography progress

+

The rings show overall progress (outer) and progress within the current sub-tomogram (inner). Remaining is the estimated duration left; Finish time 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.

+

Audio warnings

+ +

On iPhone/iPad, tap Enable once to unlock audio — this is a browser requirement and only needs to be done once per visit.

+

Beamline states

+

Lists every beamline condition currently registered with BEC, its live status (valid/invalid/warning/unknown), and whether the scan interlock is watching it. A watched state that is not in its accepted status is what actually causes the Blocked status above — states that are not watched (like an unrelated simulated shutter) can be invalid without blocking anything.

+

Other features

+

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.

+
+
+
@@ -1780,6 +2023,10 @@ def _render_html(phone_numbers: list) -> str:
Watch
+
+
+ Blocked +
Live @@ -1822,7 +2069,20 @@ def _render_html(phone_numbers: list) -> str:
- + +
+
⋮⋮
+
Beamline states
+
+ + + + + +
StateStatusWatchedLabel
+
+ +
⋮⋮
Contacts
@@ -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 '+d.idle_for_human+'', + blocked: d=>'Queue locked'+((d.queue_locks&&d.queue_locks.length>1)?' by multiple locks':'')+': ' + +(d.queue_locks||[]).map(l=>esc(l.reason||l.identifier)).join('; ')+'', error: d=>'Queue stopped unexpectedly · idle for '+(d.idle_for_human||'?')+'', 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,'>');}} 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 '+enabledTxt+' · no beamline states configured'; + tbody.innerHTML='
No beamline states configured
'; + return; + }} + + summary.innerHTML = 'Scan interlock '+enabledTxt+'' + + (mismatched.length>0 + ? ' · '+mismatched.length+' 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+='' + +''+esc(s.name)+'' + +''+esc(s.status)+'' + +''+(s.watched?'watched':'not watched')+'' + +''+esc(s.label)+'' + +''; + }}); + 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;