Multi-Dimensional Viewer (MDV) is a tool for analyzing, annotating and sharing multi-dimensional data.
This repository packages MDV as an OpenOnDemand interactive app. MDV runs inside an
Apptainer container on a compute node, with a small proxy in front of Flask to make its
absolute asset paths work behind rnode. Developed and in production on the BMRC cluster
at the University of Oxford, and intended to be portable to other OOD sites. For MDV
itself, see the upstream project.
The MIT license in this repository applies solely to the deployment scripts, configuration files, and documentation provided here for running MDV (https://mdv.ndm.ox.ac.uk/) via OpenOnDemand. MDV itself is not covered by this license.
All intellectual property rights for MDV remain with the original authors. Please refer to the original license before using, modifying, or redistributing MDV.
flowchart LR
B[Browser] -->|/rnode/host/port/...| R[OOD rnode]
R -->|strips prefix| P["proxy.py<br/>BIND_PORT"]
P -->|rewrites asset paths| F["Flask + Gunicorn<br/>BIND_PORT + 1"]
On the compute node, inside one Apptainer container:
| Component | Notes |
|---|---|
| Flask + Gunicorn | gevent worker, serves the app and API |
| Vite frontend | Built at image time into /app/dist |
| PostgreSQL | Initialised per session, not a sidecar |
| Projects directory | On GPFS, supplied by the user — persists across sessions |
The database is an index rebuilt from the projects directory at startup. The projects directory is the only thing that has to survive.
The container path lives in an Lmod module, not in the app config. The form offers a
version dropdown; the chosen module is loaded at launch and exports MDV_SIF.
-- MDV/main.lua
local version = "main"
local sif = "/apps/.../MDV/mdv-" .. version .. ".sif"
setenv("MDV_SIF", sif)
setenv("MDV_VERSION", version)# template/script.sh.erb
module load <%= context.mdv_module %>
apptainer run ... "${MDV_SIF}"To add a version: build a SIF, drop in a .lua, add one line to the form dropdown.
Nothing in the launch script changes.
MDV is designed to run as a standalone web server at the root path (/), with its Vite-built
frontend referencing static assets using absolute URLs such as /flask/assets/catalog.css and /flask/js/mdv.js.
OpenOnDemand's reverse proxy (rnode) works by routing requests through a URL of the form /rnode/<hostname>/<port>/path
on the OOD server, stripping the /rnode/<hostname>/<port> prefix before forwarding to the application. This means
the application itself always receives requests at the correct path — but the browser does not know about the stripping.
When the browser loads a page served at https://ood-server/rnode/host/port/, it resolves absolute asset paths such as
/flask/assets/catalog.css relative to the OOD server root, not through the rnode proxy. The result is 404 or 500
errors for all static assets, leaving the application as a blank page.
A secondary complication arises from nested routes. When a user opens a project, the page URL deepens to something
similar to /rnode/host/port/project/1/. Simply converting absolute paths to relative ones (e.g. flask/assets/catalog.css)
does not solve the problem here, because the browser would then resolve those relative paths against the current
sub-path, producing incorrect URLs such as /rnode/host/port/project/1/flask/assets/catalog.css.
proxy.py solves this by sitting between OOD's rnode proxy and the MDV Flask application. Flask runs on an internal port
(BIND_PORT + 1), invisible to OOD. The proxy listens on BIND_PORT (the port OOD knows about) and forwards all requests to
Flask. For HTML responses specifically, it rewrites every absolute /flask/ asset reference to a fully-qualified r
node-prefixed path — for example /flask/assets/catalog.css becomes /rnode/<hostname>/<port>/flask/assets/catalog.css.
Because these are now absolute URLs that include the full rnode path, the browser routes them correctly through OOD's
proxy to Flask regardless of the current page depth.
WebSocket connections (used by MDV's socket.io real-time features) are handled separately via a raw socket bridge, since HTTP-level proxying cannot perform the WebSocket protocol upgrade.
Rewrite to the absolute rnode-prefixed path, not a relative one. See the nested-route problem above. Relative paths appear to work until someone opens a project.
Strip Content-Encoding on the way out. Flask gzips responses; urllib silently
decompresses them. Forwarding the original Content-Encoding header makes the browser
try to gunzip plain bytes. Drop the header and send a fresh Content-Length.
MDV's internal data — the PostgreSQL cluster, logs and caches — lives in a per-session
directory on node-local /tmp, keyed by the OOD session ID. Cleanup is via a trap
on exit, with a stale-directory sweep as a backstop.
The form asks only for the project directory, matching standard MDV startup.
Why not a user-supplied data directory?
An earlier version had a second form field for a BMRC path. Three problems:
- Shared paths collide. Two sessions pointed at one data directory clash on the PostgreSQL data directory and port 5432.
- Group-write is not inherited. On shared directories, group-write permissions aren't inherited by default — sharing a directory meant manual checking each time.
- Divergence from upstream. Standard MDV startup asks only for the project directory.
Important
Caches are rebuilt each session, so expect a ~90 s numba recompile on first launch.
This is the trade-off for the /tmp move, not a bug. Projects persist regardless — the
database is rebuilt from the projects directory.
Relevant when rebuilding the SIF from the upstream Dockerfile.
Apptainer has no build stages. Multi-stage Docker builds have to be flattened; the
whole build runs sequentially in %post.
PostgreSQL over TCP, not a Unix socket. MDV constructs a postgresql://host/db URL,
so a socket path won't fit.
/var/run is read-only. Redirect the PostgreSQL lock file to writable space.