From 539dc641ec6e60c08dc4b2f3a81e6fed840c097b Mon Sep 17 00:00:00 2001 From: Sophia Guizzo Date: Wed, 1 Jul 2026 16:15:07 -0400 Subject: [PATCH 1/2] remote: stage the pulled .h5 in a per-user /tmp dir, off the home quota The remote toksearch pull staged its ~80 MB compact .h5 under ~/magnetics_fetch/out/ before copying it home; across runs this blew the GA home quota (OSError [Errno 122] Disk quota exceeded on the cluster-side h5py.File create). Stage the transient file in node-local /tmp instead (it is written, rsync'd back, and never reused), keeping only the small package sync under remote_dir, and delete the staged copy after copy-back so /tmp doesn't accumulate. Make the stage dir per-user (/tmp/magnetics_out_) so concurrent pulls on the shared login node don't collide: the username is put in the top-level /tmp entry (each user owns their own) rather than a subdir under a shared parent. The name comes from the known GA username, falling back to `id -un` on the cluster (works when $USER/$LOGNAME are unset in a non-login ssh shell). Stage dir base is overridable via run_remote(out_stage=...). Co-Authored-By: Claude Opus 4.8 --- src/magnetics/data/fetch/remote.py | 52 ++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/src/magnetics/data/fetch/remote.py b/src/magnetics/data/fetch/remote.py index f8d8ff2..e555af0 100644 --- a/src/magnetics/data/fetch/remote.py +++ b/src/magnetics/data/fetch/remote.py @@ -9,8 +9,8 @@ 2. rsync the fetcher (toksearch_fetch.py + magnetics_signals.py) up; 3. run the fetcher there with the cluster env's interpreter directly -- PTDATA is local and `toksearch_d3d` reads it natively (benchmarked ~5-7x faster than - mdsip), then writes a compact (lzf) .h5; - 4. rsync that .h5 back into the local data/datafile/ dir. + mdsip), then writes a compact (lzf) .h5 to node-local /tmp (off the home quota); + 4. rsync that .h5 back into the local data/datafile/ dir, then delete the /tmp copy. Why this beats the laptop mdsthin pull: only the *compressed* .h5 crosses the tunnel (~80 MB) instead of ~370 MB of raw float over mdsip. Measured end-to-end ~21-24s @@ -58,7 +58,14 @@ # fallback when the device omits a field. Explicit args / CLI flags override both. DEFAULT_HOST = "omega.gat.com" # real cluster host (no ~/.ssh/config alias needed) DEFAULT_JUMP = "cybele.gat.com:2039" # gateway ProxyJump to reach the cluster -DEFAULT_DIR = "~/magnetics_fetch" +DEFAULT_DIR = "~/magnetics_fetch" # code sync dir (small: just the package) +# Where the transient .h5 is staged on the cluster before it is copied back. The +# file is written, rsync'd home, and deleted -- it never needs to persist -- so we +# stage it in node-local /tmp instead of the home dir, whose quota an ~80 MB pull +# (accumulating across runs) otherwise blows ([Errno 122] Disk quota exceeded). +# A per-user suffix (`_`) is appended at run time so pulls from different +# users on the shared login node don't collide -- see run_remote. +DEFAULT_OUT_STAGE = "/tmp/magnetics_out" # Invoke the cluster env's interpreter DIRECTLY -- no `module load` / `conda activate` # (its activate.d scripts set nothing PTDATA needs; direct is ~4-5s faster per pull). DEFAULT_PYTHON = "/fusion/projects/codes/conda/omega/envs_public/toksearch_env/bin/python" @@ -79,6 +86,7 @@ def run_remote( password=None, duo=None, remote_dir=DEFAULT_DIR, + out_stage=None, python=None, tmin=None, tmax=None, @@ -99,6 +107,7 @@ def run_remote( empty string to force none (e.g. an ssh-config alias that carries its own ProxyJump).""" login = cluster_login(device) + out_stage_base = (out_stage or DEFAULT_OUT_STAGE).rstrip("/") host = host or login["host"] or DEFAULT_HOST port = login["port"] # cluster SSH port (22 unless the device overrides it) python = python or login["python"] or DEFAULT_PYTHON @@ -138,9 +147,23 @@ def run(cmd, **kw): "username / password / Duo)." ) - # 2) ensure the remote dir exists, then rsync the fetcher up. - out_remote = f"{remote_dir}/out" - run([*reuse, target, f"mkdir -p {remote_dir} {out_remote}"], check=True) + # Make the /tmp stage per-user so concurrent pulls on the SHARED login node + # don't collide: the username goes in the top-level dir name (created + # directly in sticky-bit /tmp, so each user owns their own entry) rather + # than a subdir under one shared parent (whose owner others can't write to). + # Prefer the known GA username; else ask the cluster -- `id -un` reads the + # name from the UID, so it works even when $USER/$LOGNAME are unset in this + # non-login ssh command shell (the reason `echo $USER` comes back empty). + stage_user = username + if not stage_user: + who = run([*reuse, target, "id -un"], capture_output=True, text=True) + stage_user = who.stdout.strip() if who.returncode == 0 else "" + out_stage = f"{out_stage_base}_{stage_user}" if stage_user else out_stage_base + + # 2) ensure the code sync dir + the /tmp output stage exist, then rsync the + # fetcher up. The .h5 is staged in out_stage (node-local /tmp), off the + # home quota; only the small package lives under remote_dir. + run([*reuse, target, f"mkdir -p {remote_dir} {out_stage}"], check=True) _log(f"Syncing {PKG_NAME} package → {target}:{remote_dir}/ ...") rsh = f"ssh -o ControlPath={sock}" # rsync reuses the master connection run( @@ -157,8 +180,10 @@ def run(cmd, **kw): check=True, ) - # 3) run the pull on the cluster via the env interpreter directly. - remote_out = f"out/shot_{shot}.h5" + # 3) run the pull on the cluster via the env interpreter directly. --out is + # an ABSOLUTE path in the /tmp stage, so it lands there regardless of the + # `cd {remote_dir}` the inner command does for the package imports. + remote_out = f"{out_stage}/shot_{shot}.h5" fetch = [ python, "-m", @@ -193,15 +218,22 @@ def run(cmd, **kw): if run([*reuse, target, inner]).returncode != 0: sys.exit("remote fetch failed.") - # 4) copy the result back. + # 4) copy the result back (remote_out is already an absolute /tmp path). out_dir = Path(local_out_dir) if local_out_dir else LOCAL_OUT out_dir.mkdir(parents=True, exist_ok=True) local_path = out_dir / f"shot_{shot}.h5" _log(f"Copying result → {local_path} ...") run( - ["rsync", "-az", "-e", rsh, f"{target}:{remote_dir}/{remote_out}", str(local_path)], + ["rsync", "-az", "-e", rsh, f"{target}:{remote_out}", str(local_path)], check=True, ) + # Remove the staged copy now that it's home -- /tmp is roomy but shared, so + # don't leave ~80 MB per pull lying around for the next user/run. + run( + [*reuse, target, f"rm -f {shlex.quote(remote_out)}"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) if progress: progress(1.0, f"done: {local_path.name}") _log(f"Saved {local_path}") From 435899a2882a10cb54c84baefbfe4bd1f63375b2 Mon Sep 17 00:00:00 2001 From: Sophia Guizzo Date: Wed, 1 Jul 2026 16:41:53 -0400 Subject: [PATCH 2/2] =?UTF-8?q?gui:=20delete=20a=20shot=20(=C3=97=20per=20?= =?UTF-8?q?row)=20+=20clear-all,=20removing=20its=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add data-management controls to the shot list: an × on each fetched shot deletes it, and a "Clear all" in the section header deletes every fetched shot. Both remove the underlying HDF5 file(s) on the backend, not just the GUI row. Backend: h5source.delete_shot() removes EVERY file for a shot (matched on the stored `shot` attr, so multi-file shots — different windows/fmax or a bench artifact — all go), delete_all_shots() clears them all; both refresh the file index. New DELETE /api/machines/{shot} and DELETE /api/machines routes also drop the shot's cached STFT/QS state; the per-shot route 404s when nothing matches (e.g. a mock machine). Frontend: store.removeMachine/clearMachines call the routes, re-list, and fix the selection; the × and Clear-all only render for real shots against a live backend (mock demo machines have nothing to delete), each behind a confirm() since the data loss is irreversible. Co-Authored-By: Claude Opus 4.8 --- gui/web/src/App.tsx | 48 ++++++++++++++- gui/web/src/lib/api.ts | 16 +++++ gui/web/src/store.ts | 26 +++++++- gui/web/src/theme.css | 12 +++- src/magnetics/data/h5source.py | 44 +++++++++++++ src/magnetics/service/app.py | 22 +++++++ tests/test_delete.py | 109 +++++++++++++++++++++++++++++++++ 7 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 tests/test_delete.py diff --git a/gui/web/src/App.tsx b/gui/web/src/App.tsx index 6d8303f..bee4ee5 100644 --- a/gui/web/src/App.tsx +++ b/gui/web/src/App.tsx @@ -23,9 +23,34 @@ const TABS: { id: TabId; label: string }[] = [ export default function App() { const { machines, machine, tab, loadingMachines, init, setMachine, setTab } = useStore(); + const removeMachine = useStore((s) => s.removeMachine); + const clearMachines = useStore((s) => s.clearMachines); useEffect(() => { void init(); }, [init]); + // Deletable = real fetched shots (mock demo machines have nothing on disk). Only + // offer delete controls against a live backend. + const deletable = usingLiveBackend() ? machines.filter((m) => !m.mock) : []; + + async function onDelete(id: string, label: string) { + if (!window.confirm(`Delete ${label} and all its underlying data? This cannot be undone.`)) return; + try { + await removeMachine(id); + } catch (e) { + window.alert(`Delete failed: ${String(e)}`); + } + } + + async function onClearAll() { + if (!window.confirm(`Delete ALL ${deletable.length} fetched shot(s) and their data? This cannot be undone.`)) + return; + try { + await clearMachines(); + } catch (e) { + window.alert(`Clear all failed: ${String(e)}`); + } + } + return (
@@ -39,7 +64,15 @@ export default function App() {