Fix/eps status #269
@@ -233,7 +233,12 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
|
||||
self._webpage_gen = LamniWebpageGenerator(
|
||||
bec_client=client,
|
||||
output_dir="~/data/raw/webpage/",
|
||||
upload_url="https://v1p0zyg2w9n2k9c1.myfritz.net/upload.php",
|
||||
upload_url=[
|
||||
"https://v1p0zyg2w9n2k9c1.myfritz.net/upload.php",
|
||||
# TEMP: mirroring to the test server while it's evaluated --
|
||||
# drop this line once omny-test.psi.ch is confirmed or the trial ends.
|
||||
"https://omny-test.psi.ch/upload.php",
|
||||
],
|
||||
local_port=8080,
|
||||
)
|
||||
self._webpage_gen.start()
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# Webpage generator upload mechanism
|
||||
|
||||
Consolidated reference for how the flOMNI/LamNI/OMNY status pages get uploaded to the
|
||||
remote web server(s). Supersedes the upload-related parts of
|
||||
`flomni/AI_docs/Webpage.md`, which predates `OMNY_shared/web_common.py` and still
|
||||
describes LamNI as an unimplemented stub.
|
||||
|
||||
## Live files vs. dead duplicate
|
||||
|
||||
The live implementation is:
|
||||
|
||||
- `OMNY_shared/web_common.py` -- `HttpUploader`, `LocalHttpServer`.
|
||||
- `OMNY_shared/webpage_generator_base.py` -- `WebpageGeneratorBase`, `make_webpage_generator()`.
|
||||
- Per-setup subclasses: `flomni/flomni_webpage_generator.py` (`FlomniWebpageGenerator`),
|
||||
`LamNI/LamNI_webpage_generator.py` (`LamniWebpageGenerator`). These are thin subclasses
|
||||
that forward constructor kwargs straight to `WebpageGeneratorBase.__init__` -- neither
|
||||
overrides upload handling.
|
||||
- `OMNY_shared/eps/eps_status_generator.py` -- `EpsStatusGenerator`, the separate
|
||||
EPS/machine-status daemon. It has its own `HttpUploader` construction for standalone
|
||||
use, but when launched from `WebpageGeneratorBase._launch()` it's always given
|
||||
`upload_url=None` -- its files live in the same `output_dir` and ride along on the
|
||||
parent generator's own `upload_changed_async()` scan every cycle. Giving it its own
|
||||
uploader too would re-POST the same directory redundantly.
|
||||
|
||||
**`omny/omny_webpage_generator.py` is a stale, unwired duplicate.** It defines its own
|
||||
copies of `HttpUploader`/`LocalHttpServer`/`WebpageGeneratorBase`, is never imported by
|
||||
anything, and its own docstring header still reads `"""flomni/webpage_generator.py"""`
|
||||
(a leftover from an earlier version of the flomni file). `make_webpage_generator()`
|
||||
explicitly falls back to the plain base class for the `"omny"` session rather than using
|
||||
it. **Do not edit this file** -- a `grep -rn "HttpUploader"` across `plugins/` will
|
||||
surface it as a false positive; always check the import path before editing.
|
||||
|
||||
## `HttpUploader` contract (`web_common.py`)
|
||||
|
||||
Uploads files from a local directory to one or more remote servers via HTTP POST, in a
|
||||
background daemon thread so uploads never block the generator's cycle.
|
||||
|
||||
- `_UPLOAD_SUFFIXES = {".html", ".json", ".jpg", ".png", ".pdf", ".txt", ".svg"}` --
|
||||
files with any other extension are silently skipped by the directory-scanning methods
|
||||
(`upload_dir_async`/`upload_changed_async`). `upload_file_async` bypasses this whitelist
|
||||
entirely (used only for `session.htpasswd`, which has no matching extension).
|
||||
- **Fire-and-forget, drop-if-busy**: `_dispatch()` guards a single `_busy` flag under a
|
||||
lock. If a previous upload from this `HttpUploader` is still running when the next
|
||||
cycle fires, the new request is dropped (logged at DEBUG) -- there is no queueing or
|
||||
retry. One dispatched thread does all configured URLs sequentially.
|
||||
- **Per-URL mtime cache**: `self._uploaded` is `dict[url, dict[path_str, mtime]]`. A file
|
||||
is only re-uploaded to a given URL once its mtime changes since the last *successful*
|
||||
upload to that specific URL. This means each configured server tracks "changed" fully
|
||||
independently -- a URL that's been failing doesn't block another URL from proceeding,
|
||||
and a brand-new URL (empty cache) naturally gets backfilled the next time a full
|
||||
(`force=True`) upload runs.
|
||||
- **Rate-limited warning logging**: `_warn(key, msg)` logs at most once per 600s per key.
|
||||
Keys fold in the URL (e.g. `f"{url}|upload_{filename}"`, `f"{url}|cleanup"`) so a
|
||||
persistent failure against one server doesn't suppress the warning for another.
|
||||
- **SSRF hardening**: every POST uses `allow_redirects=False` -- a compromised public
|
||||
server must not be able to redirect the internal process to an arbitrary internal
|
||||
address. `verify=False` accepts self-signed certs (the fallback Raspberry Pi target
|
||||
historically used one).
|
||||
- **Ptycho cleanup**: `cleanup_ptycho_images_async()` POSTs `{"action": "cleanup"}` to
|
||||
each URL, asking the server to delete stale `S*_*.png`/`S*_*.jpg` files, then forgets
|
||||
the local mtime records for any cached path containing `/S` or `\S` so those files get
|
||||
re-uploaded on the next cycle.
|
||||
|
||||
### Multi-URL mirroring
|
||||
|
||||
`HttpUploader(url)` accepts either a single URL string or a list of URLs. When a list is
|
||||
given, **every** method (`upload_dir_async`, `upload_changed_async`, `upload_file_async`,
|
||||
`cleanup_ptycho_images_async`) mirrors uniformly to all of them, including
|
||||
`session.htpasswd` pushes -- there is no special-casing of any file or action per URL.
|
||||
|
||||
`WebpageGeneratorBase.__init__`'s `_session_query_url`/`_set_password_url` (used for
|
||||
account-match checking and `set_web_password()`) are **not** part of `HttpUploader` and
|
||||
are derived only from the *first/primary* URL, even when `upload_url` is a list -- they
|
||||
govern access control for the production page and have no meaning for a secondary/test
|
||||
mirror.
|
||||
|
||||
As of 2026-07, both `flomni.py` and `lamni.py` pass a two-element list -- production
|
||||
(`https://v1p0zyg2w9n2k9c1.myfritz.net/upload.php`) plus a temporary evaluation target
|
||||
(`https://omny-test.psi.ch/upload.php`) -- marked with a `# TEMP` comment at the call
|
||||
site. Drop the second entry once the trial ends.
|
||||
|
||||
## Call-site map (`webpage_generator_base.py`)
|
||||
|
||||
- `_launch()`: on startup (unless `local_only`), `self._uploader.upload_dir_async(...)`
|
||||
uploads every eligible file once. Also prints `self._uploader._urls` (joined) in the
|
||||
startup log line.
|
||||
- `_cycle()` (every ~15s): if the ptycho scan id changed since the last cycle, triggers
|
||||
`cleanup_ptycho_images_async()` then a delayed (2s) `upload_dir_async()` full re-upload;
|
||||
otherwise just `upload_changed_async()`. Skipped entirely when `self._uploader is None`
|
||||
or `self._local_only` is True.
|
||||
- `_clear_session_htpasswd()`: writes an emptied `session.htpasswd` locally and pushes it
|
||||
via `upload_file_async()` whenever the active BEC account changes.
|
||||
- `status()`: prints `self._uploader._urls` (joined) as the "Upload URL" line.
|
||||
|
||||
`local_only` (the account-mismatch fallback, entered automatically by `start()` unless
|
||||
`force=True`) suppresses **all** uploads regardless of how many URLs are configured --
|
||||
it's a binary "upload at all vs. not" switch, orthogonal to how many mirrors exist.
|
||||
|
||||
## Known issue: `.svg` upload failing on `omny-test.psi.ch`
|
||||
|
||||
Both the live `HttpUploader._UPLOAD_SUFFIXES` and the in-repo reference
|
||||
`OMNY_shared/server_side/webserver/upload.php` already allow `.svg`. Manual testing
|
||||
against `omny-test.psi.ch` showed `.json`/`.html` status updates succeeding but `.svg`
|
||||
(the EPS synoptic image, generated by `OMNY_shared/eps/eps_synoptic_svg.py`) failing.
|
||||
Since nothing in this repo's client or reference server code excludes `.svg`, the most
|
||||
likely cause is whatever `upload.php`/WAF is actually deployed on the test box differing
|
||||
from the reference copy here (a stricter filename regex, or a WAF rule flagging SVG's
|
||||
potential for embedded `<script>` content). This is a server-side configuration question
|
||||
for the test box, not a client bug -- follow up there before assuming a code fix is
|
||||
needed.
|
||||
@@ -288,7 +288,7 @@ function renderZones(d){
|
||||
let seeded=sessionStorage.getItem(OPEN_KEY)!==null;
|
||||
async function tick(){
|
||||
try{
|
||||
const r=await fetch("eps_status.json?t="+Date.now(),{cache:"no-store"});
|
||||
const r=await fetch("eps_status.json",{cache:"no-store"});
|
||||
if(!r.ok) throw new Error("HTTP "+r.status);
|
||||
const data=await r.json();
|
||||
if(!seeded){
|
||||
|
||||
@@ -67,7 +67,7 @@ function ago(e){ if(!e) return "never"; const d=Date.now()/1000-e;
|
||||
|
||||
let svgEl=null, byPv=new Map();
|
||||
async function loadSvg(){
|
||||
const r=await fetch("eps_synoptic.svg?t="+Date.now(),{cache:"no-store"});
|
||||
const r=await fetch("eps_synoptic.svg",{cache:"no-store"});
|
||||
const txt=await r.text();
|
||||
document.getElementById("vp").innerHTML=txt;
|
||||
svgEl=document.getElementById("eps-synoptic-svg");
|
||||
@@ -209,7 +209,7 @@ window.addEventListener("resize",fit);
|
||||
|
||||
async function tick(){
|
||||
try{
|
||||
const r=await fetch("eps_status.json?t="+Date.now(),{cache:"no-store"});
|
||||
const r=await fetch("eps_status.json",{cache:"no-store"});
|
||||
if(r.ok) applyData(await r.json());
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
@@ -47,20 +47,24 @@ def _epoch() -> float:
|
||||
|
||||
class HttpUploader:
|
||||
"""
|
||||
Uploads files from a local directory to a remote server via HTTP POST.
|
||||
Uploads files from a local directory to one or more remote servers via
|
||||
HTTP POST.
|
||||
|
||||
Uploads run in a daemon thread so they never block the caller's cycle.
|
||||
If an upload is already in progress when the next cycle fires, the new
|
||||
request is dropped (logged at DEBUG) rather than queuing up.
|
||||
request is dropped (logged at DEBUG) rather than queuing up. Passing a
|
||||
list of URLs mirrors every upload to each of them independently (each
|
||||
URL gets its own mtime cache, so a newly-added URL is backfilled with a
|
||||
full upload and a persistently failing URL doesn't affect the others).
|
||||
"""
|
||||
|
||||
_UPLOAD_SUFFIXES = {".html", ".json", ".jpg", ".png", ".pdf", ".txt", ".svg"}
|
||||
_WARN_COOLDOWN_S = 600
|
||||
|
||||
def __init__(self, url: str, timeout: float = 20.0):
|
||||
self._url = url
|
||||
def __init__(self, url: str | list[str], timeout: float = 20.0):
|
||||
self._urls = [url] if isinstance(url, str) else list(url)
|
||||
self._timeout = timeout
|
||||
self._uploaded: dict[str, float] = {}
|
||||
self._uploaded: dict[str, dict[str, float]] = {u: {} for u in self._urls}
|
||||
self._lock = threading.Lock()
|
||||
self._busy = False
|
||||
self._warn_at: dict[str, float] = {}
|
||||
@@ -75,22 +79,21 @@ class HttpUploader:
|
||||
# -- public API ---------------------------------------------------------
|
||||
|
||||
def upload_dir_async(self, directory: Path) -> None:
|
||||
"""Upload ALL eligible files in directory, in a background thread."""
|
||||
self._dispatch(self._upload_files, self._eligible_files(directory), True)
|
||||
"""Upload ALL eligible files in directory to every URL, in a background thread."""
|
||||
files = self._eligible_files(directory)
|
||||
self._dispatch(self._upload_files_all, files, True)
|
||||
|
||||
def upload_changed_async(self, directory: Path) -> None:
|
||||
"""Upload only files whose mtime changed since the last upload."""
|
||||
files = self._changed_files(directory)
|
||||
if files:
|
||||
self._dispatch(self._upload_files, files, False)
|
||||
"""Upload, to every URL, only the files whose mtime changed since that URL's last upload."""
|
||||
self._dispatch(self._upload_changed_all, directory)
|
||||
|
||||
def upload_file_async(self, path: Path) -> None:
|
||||
"""Upload one specific file, bypassing suffix whitelist and mtime check."""
|
||||
self._dispatch(self._upload_files, [Path(path)], True)
|
||||
"""Upload one specific file to every URL, bypassing suffix whitelist and mtime check."""
|
||||
self._dispatch(self._upload_files_all, [Path(path)], True)
|
||||
|
||||
def cleanup_ptycho_images_async(self) -> None:
|
||||
"""Ask the server to delete all S*_*.png / S*_*.jpg files (background)."""
|
||||
self._dispatch(self._do_cleanup)
|
||||
"""Ask every server to delete all S*_*.png / S*_*.jpg files (background)."""
|
||||
self._dispatch(self._do_cleanup_all)
|
||||
|
||||
# -- internals ----------------------------------------------------------
|
||||
|
||||
@@ -117,18 +120,33 @@ class HttpUploader:
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
def _changed_files(self, directory: Path) -> list:
|
||||
def _changed_files(self, directory: Path, url: str) -> list:
|
||||
out = []
|
||||
uploaded = self._uploaded[url]
|
||||
for path in self._eligible_files(directory):
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if self._uploaded.get(str(path)) != mtime:
|
||||
if uploaded.get(str(path)) != mtime:
|
||||
out.append(path)
|
||||
return out
|
||||
|
||||
def _upload_files(self, files: list, force: bool = False) -> None:
|
||||
def _upload_changed_all(self, directory: Path) -> None:
|
||||
for url in self._urls:
|
||||
files = self._changed_files(directory, url)
|
||||
if files:
|
||||
self._upload_files(url, files, force=False)
|
||||
|
||||
def _upload_files_all(self, files: list, force: bool) -> None:
|
||||
for url in self._urls:
|
||||
self._upload_files(url, files, force=force)
|
||||
|
||||
def _do_cleanup_all(self) -> None:
|
||||
for url in self._urls:
|
||||
self._do_cleanup(url)
|
||||
|
||||
def _upload_files(self, url: str, files: list, force: bool = False) -> None:
|
||||
try:
|
||||
import requests as _requests
|
||||
import urllib3
|
||||
@@ -137,37 +155,38 @@ class HttpUploader:
|
||||
self._warn("no_requests", "HttpUploader: 'requests' library not installed")
|
||||
return
|
||||
|
||||
uploaded = self._uploaded[url]
|
||||
for path in files:
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if not force and self._uploaded.get(str(path)) == mtime:
|
||||
if not force and uploaded.get(str(path)) == mtime:
|
||||
continue
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
r = _requests.post(
|
||||
self._url,
|
||||
url,
|
||||
files={"file": (path.name, fh)},
|
||||
timeout=self._timeout,
|
||||
verify=False, # accept self-signed certs
|
||||
allow_redirects=False, # SSRF hardening
|
||||
)
|
||||
if r.status_code == 200:
|
||||
self._uploaded[str(path)] = mtime
|
||||
self._warn_at.pop(f"upload_{path.name}", None)
|
||||
logger.debug(f"HttpUploader: OK {path.name}")
|
||||
uploaded[str(path)] = mtime
|
||||
self._warn_at.pop(f"{url}|upload_{path.name}", None)
|
||||
logger.debug(f"HttpUploader: OK {path.name} -> {url}")
|
||||
else:
|
||||
self._warn(
|
||||
f"upload_{path.name}",
|
||||
f"HttpUploader: {path.name} -> HTTP {r.status_code}: "
|
||||
f"{url}|upload_{path.name}",
|
||||
f"HttpUploader: {path.name} -> {url} -> HTTP {r.status_code}: "
|
||||
f"{r.text[:120]}",
|
||||
)
|
||||
except Exception as exc:
|
||||
self._warn(f"upload_{path.name}",
|
||||
f"HttpUploader: {path.name} failed: {exc}")
|
||||
self._warn(f"{url}|upload_{path.name}",
|
||||
f"HttpUploader: {path.name} -> {url} failed: {exc}")
|
||||
|
||||
def _do_cleanup(self) -> None:
|
||||
def _do_cleanup(self, url: str) -> None:
|
||||
try:
|
||||
import requests as _requests
|
||||
import urllib3
|
||||
@@ -176,21 +195,22 @@ class HttpUploader:
|
||||
return
|
||||
try:
|
||||
r = _requests.post(
|
||||
self._url,
|
||||
url,
|
||||
data={"action": "cleanup"},
|
||||
timeout=self._timeout,
|
||||
verify=False, # accept self-signed certs
|
||||
allow_redirects=False, # SSRF hardening
|
||||
)
|
||||
logger.info(f"HttpUploader cleanup: {r.text[:120]}")
|
||||
self._warn_at.pop("cleanup", None)
|
||||
logger.info(f"HttpUploader cleanup ({url}): {r.text[:120]}")
|
||||
self._warn_at.pop(f"{url}|cleanup", None)
|
||||
# Forget mtime records for ptycho files so they get re-uploaded
|
||||
uploaded = self._uploaded[url]
|
||||
with self._lock:
|
||||
to_remove = [k for k in self._uploaded if "/S" in k or "\\S" in k]
|
||||
to_remove = [k for k in uploaded if "/S" in k or "\\S" in k]
|
||||
for k in to_remove:
|
||||
self._uploaded.pop(k, None)
|
||||
uploaded.pop(k, None)
|
||||
except Exception as exc:
|
||||
self._warn("cleanup", f"HttpUploader cleanup failed: {exc}")
|
||||
self._warn(f"{url}|cleanup", f"HttpUploader cleanup ({url}) failed: {exc}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -464,7 +464,7 @@ class WebpageGeneratorBase:
|
||||
output_dir: str = "~/data/raw/webpage/",
|
||||
cycle_interval: float = _CYCLE_INTERVAL_S,
|
||||
verbosity: int = VERBOSITY_NORMAL,
|
||||
upload_url: str = None,
|
||||
upload_url: str | list[str] = None,
|
||||
local_port: int = 8080,
|
||||
mismatch_local_port: int = _MISMATCH_LOCAL_PORT,
|
||||
):
|
||||
@@ -479,10 +479,17 @@ class WebpageGeneratorBase:
|
||||
self._local_only = False # True while running the account-mismatch fallback
|
||||
self._eps = None # EpsStatusGenerator, constructed in _launch()
|
||||
|
||||
# Derive companion URLs from upload_url
|
||||
# Derive companion URLs from upload_url. When upload_url is a list
|
||||
# (mirroring uploads to several servers), the account-match /
|
||||
# password-setting endpoints are only ever derived from the primary
|
||||
# (first) URL -- they govern access to the production page and are
|
||||
# not part of HttpUploader's mirrored-upload path.
|
||||
if upload_url is not None:
|
||||
self._session_query_url = upload_url.replace("upload.php", "session_query.php")
|
||||
self._set_password_url = upload_url.replace("upload.php", "set_password.php")
|
||||
primary_upload_url = (
|
||||
upload_url[0] if isinstance(upload_url, (list, tuple)) else upload_url
|
||||
)
|
||||
self._session_query_url = primary_upload_url.replace("upload.php", "session_query.php")
|
||||
self._set_password_url = primary_upload_url.replace("upload.php", "set_password.php")
|
||||
else:
|
||||
self._session_query_url = None
|
||||
self._set_password_url = None
|
||||
@@ -639,7 +646,7 @@ class WebpageGeneratorBase:
|
||||
self._log(VERBOSITY_NORMAL,
|
||||
f"WebpageGenerator started{mode_note} owner={self._owner_id} "
|
||||
f"output={self._output_dir} interval={self._cycle_interval}s"
|
||||
+ (f" upload={self._uploader._url}"
|
||||
+ (f" upload={', '.join(self._uploader._urls)}"
|
||||
if (self._uploader and not local_only) else " upload=disabled")
|
||||
+ f"\n ➜ Status page:{local_url_msg}")
|
||||
|
||||
@@ -692,7 +699,7 @@ class WebpageGeneratorBase:
|
||||
f" Output dir : {self._output_dir}\n"
|
||||
f" Local URL : {local}\n"
|
||||
f" Cycle interval : {self._cycle_interval}s\n"
|
||||
f" Upload URL : {self._uploader._url if self._uploader else 'disabled'}\n"
|
||||
f" Upload URL : {', '.join(self._uploader._urls) if self._uploader else 'disabled'}\n"
|
||||
f" Session query URL : {self._session_query_url or 'disabled'}\n"
|
||||
f" BEC active account : {active_account}\n"
|
||||
f" Server session user : {remote_user}\n"
|
||||
@@ -2603,7 +2610,8 @@ function renderInstrument(setup){{
|
||||
const card=document.getElementById('instrument-card'),grid=document.getElementById('instrument-grid');
|
||||
const cp=(setup&&setup.current_params)||null;
|
||||
const hasParams=cp&&Object.keys(cp).length>0;
|
||||
if(!setup||(!setup.temperatures&&!hasParams)){{card.style.display='none';return;}}
|
||||
const hasTemps=setup&&setup.temperatures&&Object.keys(setup.temperatures).length>0;
|
||||
if(!setup||(!hasTemps&&!hasParams)){{card.style.display='none';return;}}
|
||||
card.style.display='';
|
||||
let html='';
|
||||
if(hasParams){{
|
||||
@@ -2615,7 +2623,7 @@ function renderInstrument(setup){{
|
||||
html+=buildParamRows(cp);
|
||||
html+='</table></div>';
|
||||
}}
|
||||
if(setup.temperatures){{
|
||||
if(hasTemps){{
|
||||
html+='<div class="instrument-section"><h3>Temperatures (°C)</h3><table class="kv-table">';
|
||||
for(const[k,v] of Object.entries(setup.temperatures)){{
|
||||
const cls=v==null?' class="temp-null"':'';
|
||||
|
||||
@@ -1642,7 +1642,12 @@ class Flomni(
|
||||
bec_client=client,
|
||||
output_dir="~/data/raw/webpage/",
|
||||
# upload_url="http://s1090968537.online.de/upload.php", # optional
|
||||
upload_url="https://v1p0zyg2w9n2k9c1.myfritz.net/upload.php",
|
||||
upload_url=[
|
||||
"https://v1p0zyg2w9n2k9c1.myfritz.net/upload.php",
|
||||
# TEMP: mirroring to the test server while it's evaluated --
|
||||
# drop this line once omny-test.psi.ch is confirmed or the trial ends.
|
||||
"https://omny-test.psi.ch/upload.php",
|
||||
],
|
||||
local_port=8080,
|
||||
)
|
||||
self._webpage_gen.start()
|
||||
|
||||
Reference in New Issue
Block a user