From bc9589f0f6fc512aaaecaf899135db5a0c3ed796 Mon Sep 17 00:00:00 2001 From: WillForan Date: Sat, 15 Feb 2025 21:50:13 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20js:add=5Fnew=5Fseries=20needs=20?= =?UTF-8?q?object=20from=20mktable,py:check=5Fhdr=20for=20ws=20like=20/sta?= =?UTF-8?q?te=20json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mrqart.py | 64 +++++++++++++++------- static/index.html | 136 +++++++++++++++++++++------------------------- 2 files changed, 106 insertions(+), 94 deletions(-) diff --git a/mrqart.py b/mrqart.py index b675f0ea..9d085663 100755 --- a/mrqart.py +++ b/mrqart.py @@ -10,13 +10,14 @@ import logging import os import re +from typing import Optional import aionotify from tornado.httpserver import HTTPServer from tornado.web import Application, RequestHandler from websockets.asyncio.server import broadcast, serve -from template_checker import TemplateChecker +from template_checker import TemplateChecker, CheckResult Station = str Sequence = str @@ -34,6 +35,8 @@ def __init__(self, station: Station): self.station = station self.series_seqname = "" self.count = 0 + #: set using dcm_checker.check_header + self.hdr_check: Optional[CheckResult]= None def update_isnew(self, series, seqname: Sequence) -> bool: """ @@ -97,7 +100,24 @@ class GetState(RequestHandler): """Return the current state as JSON""" async def get(self): - self.write(json.dumps({k: repr(v) for k, v in STATE.items()})) + """ + GET /state returns JSON similiar to data sent over websocket + via broadcast(WS_CONNECTIONS, json.dumps(...)) + + data here missing 'msg' but otherwise matches. it looks like + {'station': 'AWP167046', + 'content': {"conforms": true, "errors": {}, + "template": {"iPAT": "p2", ...}, + "input": {"iPAT":"p2", .... }}} + """ + #: 'station', 'content', and (not here) 'msg' (update|new) + #: are sent when inotify sees a new file. + #: mimic that for code reuse on javascript side + state_like_ws = {k: {'station': v.station, + 'content': v.hdr_check} + for k, v in STATE.items()} + logging.debug("/state data sent: %s", state_like_ws) + self.write(json.dumps(state_like_ws, default=str)) class HttpIndex(RequestHandler): @@ -105,6 +125,8 @@ class HttpIndex(RequestHandler): async def get(self): """Default is just the index page""" + # TODO: replace websocket port with one given on CLI? + # ... or provide new route that specifies websocket port self.render("static/index.html") @@ -163,14 +185,6 @@ async def monitor_dirs(watcher, dcm_checker): event = await watcher.get_event() - # Refresh state every 60 seconds if no new event is found - if not event: - logging.info("Refreshing state...") - logging.debug("STATE before clearing: %s", STATE) - STATE.clear() - await asyncio.sleep(60) # 60 is the first attempt, we will see what works - continue - logging.debug("got event %s", event) file = os.path.join(event.alias, event.name) @@ -192,30 +206,40 @@ async def monitor_dirs(watcher, dcm_checker): logging.debug("DICOM HEADER: %s", hdr) - current_ses = STATE.get(hdr["Station"]) + station = hdr["Station"] + current_ses = STATE.get(station) if not current_ses: - STATE[hdr["Station"]] = CurSeqStation(hdr["Station"]) - current_ses = STATE.get(hdr["Station"]) + STATE[station] = CurSeqStation(station) + current_ses = STATE.get(station) # only send to browser if new - # TODO: what if browser started up rate + # browser will check /state (HTTP instead of WS) + # if it messes this new if current_ses.update_isnew(hdr["SeriesNumber"], hdr["SequenceName"]): logging.debug("first time seeing %s", current_ses) + + # keep this in memory in case browser asks for it again (HTTP vs WS) + # see '/state' route and GetState + hdr_check = dcm_checker.check_header(hdr) + STATE[station].hdr_check = hdr_check + + msg = { - "station": hdr["Station"], + "station": station, "type": "new", - "content": dcm_checker.check_header(hdr), + "content": hdr_check, } + # logging here but not update logging.debug(msg) - broadcast(WS_CONNECTIONS, json.dumps(msg, default=list)) else: msg = { "station": hdr["Station"], "type": "update", "content": current_ses.count, } - broadcast(WS_CONNECTIONS, json.dumps(msg, default=list)) - logging.debug("already have %s", STATE[hdr["Station"]]) + logging.debug("already have %s", current_ses) + # send data to browser via websocket + broadcast(WS_CONNECTIONS, json.dumps(msg, default=list)) # TODO: if epi maybe try plotting motion? # async alignment @@ -231,7 +255,7 @@ async def main(paths): Run all services on different threads. HTTP and inotify are forked. Websocket holds the main thread. """ - dcm_checker = TemplateChecker() + dcm_checker = TemplateChecker() # TODO: this can be defined globally for the package? watcher = aionotify.Watcher() for path in paths: logging.info("watching %s", path) diff --git a/static/index.html b/static/index.html index 110202e9..f5bf2f27 100644 --- a/static/index.html +++ b/static/index.html @@ -9,18 +9,19 @@ /* Main websocket data parser. dispatches base on message type */ function receivedMessage(msg) { data = JSON.parse(msg.data); - console.log("new message data:", data); + console.log("New message from websocket:", data); if (data['type'] == 'new') { add_new_series(data['content']); } if (data['type'] == 'update') { - console.log("Received update message:", data); - - // Always fetch state when receiving an update - console.warn("Fetching state after update..."); - fetchState(); + console.log("ws msg is update"); + let seq = document.getElementById("stations"); + if(is_fresh_page()){ + console.warn("websocket update w/o browser state! HTTP fetch to update browser."); + fetchState(); + } } } @@ -30,53 +31,23 @@ .then(response => response.json()) .then(data => { console.log("Fetched state:", data); - - if (Object.keys(data).length === 0) { - console.warn("Fetched empty state, retrying in 5 seconds..."); - setTimeout(fetchState, 5000); - } else { - console.warn("Updating UI with fetched state..."); - updateUIFromState(data); - } + console.warn("Updating UI with fetched state..."); + updateUIFromState(data); }) .catch(err => { + // will wait for next websockets push. hopefully no error then console.error("Error fetching state:", err); - console.warn("Retrying state fetch in 5 seconds..."); - setTimeout(fetchState, 5000); }); } // Update UI with the fetched state function updateUIFromState(stateData) { - const stationList = document.getElementById("stations"); // Loop through all stations in the fetched state - for (const [station, session] of Object.entries(stateData)) { - let stationId = `station-${station}`; - let existingStation = document.getElementById(stationId); - - // If the station entry doesn't exist, create it - if (!existingStation) { - existingStation = document.createElement("div"); - existingStation.id = stationId; - stationList.appendChild(existingStation); - } - - // Extract session details - let sessionParts = session.split(" "); - let seqNumber = sessionParts[1] || "Unknown"; - let seqName = sessionParts.slice(2, -1).join(" ") || "Unknown"; - let count = sessionParts[sessionParts.length - 1] || "0"; - - // Update the UI content (replace text instead of appending) - existingStation.innerHTML = `${station}: Seq ${seqNumber}, ${seqName} (Count: ${count})`; - - // Ensure the compliance table remains visible - let complianceTable = document.querySelector(".compliance-table"); - if (complianceTable) { - stationList.prepend(complianceTable); - } - } + for (const [station, msg] of Object.entries(stateData)) { + console.log(`adding ${station} data`, msg) + add_new_series(msg['content']); + } } // Fetch state on page load @@ -95,34 +66,55 @@ // new dicom data into table +/* returns table html element + will be embeded in collapsable 'details' for a sequence + elements styled by main.css such that + * correct/expect ("conform" class) is small and grayed out + * errors/unexpected values ("non-conform" class) are big and red + used by 'add_new_series()' +*/ function mktable(data) { const keys = [ "SequenceType", "Phase", "PED_major", "TR", "TE", "Matrix", "PixelResol", "FoV", "BWP", "BWPPE", "FA", "TA", "iPAT", "Shims" ]; + let table = ""; + /*Object.entries(data['input']).map( + ([k,v]) => ``) + + */ + let rows = ["", "", ""] + for(const k of keys){ + conf_css = ((k in data['errors'])?"no-conform":"conform") + rows[0] += ``;; + rows[1] += ``; + rows[2] += ``; + } + table += `${rows[0]}` + + `${rows[1]}` + + `${rows[2]}`; + table += "
${k}${v}${data['template'][k]}
inputtemplate${k}${data['input'][k]}${data['template'][k]}
"; + return(table) +} - let table = document.querySelector(".compliance-table"); - - // If table doesn't exist, create it - if (!table) { - table = document.createElement("table"); - table.classList.add("compliance-table"); - document.getElementById("stations").prepend(table); - } - - let rows = ["", "input", "template"]; - for (const k of keys) { - let conf_css = (k in data['errors']) ? "no-conform" : "conform"; - rows[0] += `${k}`; - rows[1] += `${data['input'][k]}`; - rows[2] += `${data['template'][k]}`; - } - - table.innerHTML = `${rows[0]}${rows[1]}${rows[2]}`; +/* have we seen any data? */ +function is_fresh_page(){ + let seq = document.getElementById("stations"); + return(seq.innerHTML === "waiting for scanner" || seq.innerHTML === "") } -/* what to do with type=="new" data: a dicom from a new sequence */ +/* what to do with type=="new" data: a dicom from a new sequence + @param data object with keys 'input', 'template', 'errors', and 'conforms' + the 'content' part of websocket (or /state fetch) message + cf. msg['station'] and msg['type'] + message built in python by 'monitor_dirs' (WS) or 'GetState' (HTTP) + 'input' is dicom hdr. + 'template' is the extected values + 'errors' enumrate all paramters in input not matching template + 'conforms' is true/false. when false, 'errors' should be {} +*/ function add_new_series(data) { let el = document.createElement("li"); el.className = data['conforms'] ? 'conform' : 'no-conform'; @@ -143,18 +135,12 @@ let note = `
${summary}`; - // Replace table instead of appending multiple tables - let existingTable = document.querySelector(".compliance-table"); - if (existingTable) { - existingTable.remove(); - } - note += "
" + mktable(data) + "
"; el.innerHTML = note; // Clear "waiting for scanner" text let seq = document.getElementById("stations"); - if (seq.innerHTML === "waiting for scanner") { + if(is_fresh_page()) { seq.innerHTML = ""; } @@ -171,9 +157,9 @@ station = newStation; } - // Replace content instead of appending multiple times - station.innerHTML = ""; - station.appendChild(el); + // browser accumulates sequences it's seen + // newest is always on top + station.prepend(el); } // TODO: parse url to set @@ -189,8 +175,9 @@ /* hidden text box to test sending what would come from websocket */ function show_debug(){ - document.getElementsByClassName("debug")[0].style="" - //$("#select_station").children.map((el) => el.style.visibility="visible") + let cur = document.getElementsByClassName("debug")[0].style.display; + let toggled = cur==='block'?'none':'block'; + document.getElementsByClassName("debug")[0].style.display=toggled; } function simdata(){ let data = document.getElementById("debug"); @@ -201,6 +188,7 @@ +
🐛
MR Station:
waiting for scanner