From 628824407f2cfca78aa596d59e786e8fa506bfa5 Mon Sep 17 00:00:00 2001 From: udo Date: Sun, 11 Oct 2026 00:31:43 +0000 Subject: [PATCH] Add private PHP scientific workbench and native map editor --- LOCAL.md | 2 +- config/web.json | 30 ++ deploy/exoplasim-worker.service | 26 + docs/web-ui.md | 89 ++++ docs/work/2026-10-10/gui-editor-tests.md | 15 + docs/work/2026-10-10/live-http-tests.md | 23 + docs/work/2026-10-10/rgb-output.md | 19 + docs/work/2026-10-10/web-acceptance.md | 77 +++ docs/work/2026-10-11/final-ui.md | 26 + docs/work/web-ui.md | 147 +++++ scripts/install-web | 55 ++ scripts/web/config.py | 20 + scripts/web/mapcatalog.py | 23 + scripts/web/model.py | 87 +++ scripts/web/parameters.py | 238 +++++++++ scripts/web/planet.py | 49 ++ scripts/web/results.py | 175 ++++++ scripts/web/terrain.py | 208 ++++++++ tests/test_web_model.py | 105 ++++ tests/test_web_parameters.py | 98 ++++ tests/test_web_planet.py | 101 ++++ tests/test_web_results.py | 79 +++ tests/web-api.php | 103 ++++ tests/web-backend.php | 59 +++ tests/web-composite.js | 40 ++ tests/web-gui-composite.js | 25 + tests/web-gui-editor.js | 55 ++ tests/web-gui-numbers.py | 36 ++ tests/web-gui-workbench.js | 40 ++ tests/web-http.php | 11 + tests/web-installer.py | 87 +++ tests/web-live-http.py | 107 ++++ tests/web-map-brush.js | 18 + tests/web-public.php | 14 + tests/web-worker.php | 64 +++ web/.gitignore | 66 +++ web/AGENTS.md | 15 + web/README.md | 7 + web/components/exoplasim/workbench.php | 72 +++ web/components/theme/shell.php | 80 +++ web/index.php | 18 + web/js/composite.js | 41 ++ web/js/map-editor.js | 161 ++++++ web/js/morphdom.js | 1 + web/js/site.js | 65 +++ web/js/u-macrobars.md | 137 +++++ web/js/u-query.js | 589 +++++++++++++++++++++ web/js/visualization.js | 176 ++++++ web/js/workbench.js | 85 +++ web/lib/components.php | 262 +++++++++ web/lib/experiment.class.php | 26 + web/lib/filebase.class.php | 286 ++++++++++ web/lib/job.class.php | 39 ++ web/lib/labdb.class.php | 20 + web/lib/log.class.php | 41 ++ web/lib/mapcatalog.class.php | 10 + web/lib/maps.class.php | 40 ++ web/lib/parametercatalog.class.php | 50 ++ web/lib/profiler.class.php | 42 ++ web/lib/results.class.php | 14 + web/lib/ulib.php | 271 ++++++++++ web/lib/url.class.php | 14 + web/server/schema.sql | 28 + web/server/worker.php | 178 +++++++ web/tests/bootstrap.php | 18 + web/tests/components/callable-fixture.php | 5 + web/tests/components/multi-fixture.php | 5 + web/tests/exoplasim-workbench-smoke.php | 18 + web/tests/filebase-path-guard.php | 52 ++ web/tests/smoke.php | 37 ++ web/themes/common/page.blank.php | 1 + web/themes/common/page.json.php | 12 + web/themes/exoplasim/css/map-editor.css | 3 + web/themes/exoplasim/css/style.css | 1 + web/themes/exoplasim/css/visualization.css | 1 + web/themes/exoplasim/page.html.php | 12 + web/views/api/index.php | 129 +++++ web/views/elements/error.php | 5 + web/views/index.php | 10 + 79 files changed, 5493 insertions(+), 1 deletion(-) create mode 100644 config/web.json create mode 100644 deploy/exoplasim-worker.service create mode 100644 docs/web-ui.md create mode 100644 docs/work/2026-10-10/gui-editor-tests.md create mode 100644 docs/work/2026-10-10/live-http-tests.md create mode 100644 docs/work/2026-10-10/rgb-output.md create mode 100644 docs/work/2026-10-10/web-acceptance.md create mode 100644 docs/work/2026-10-11/final-ui.md create mode 100644 docs/work/web-ui.md create mode 100755 scripts/install-web create mode 100644 scripts/web/config.py create mode 100644 scripts/web/mapcatalog.py create mode 100644 scripts/web/model.py create mode 100644 scripts/web/parameters.py create mode 100644 scripts/web/planet.py create mode 100644 scripts/web/results.py create mode 100644 scripts/web/terrain.py create mode 100644 tests/test_web_model.py create mode 100644 tests/test_web_parameters.py create mode 100644 tests/test_web_planet.py create mode 100644 tests/test_web_results.py create mode 100644 tests/web-api.php create mode 100644 tests/web-backend.php create mode 100644 tests/web-composite.js create mode 100644 tests/web-gui-composite.js create mode 100644 tests/web-gui-editor.js create mode 100644 tests/web-gui-numbers.py create mode 100644 tests/web-gui-workbench.js create mode 100644 tests/web-http.php create mode 100644 tests/web-installer.py create mode 100644 tests/web-live-http.py create mode 100644 tests/web-map-brush.js create mode 100644 tests/web-public.php create mode 100644 tests/web-worker.php create mode 100644 web/.gitignore create mode 100644 web/AGENTS.md create mode 100644 web/README.md create mode 100644 web/components/exoplasim/workbench.php create mode 100644 web/components/theme/shell.php create mode 100644 web/index.php create mode 100644 web/js/composite.js create mode 100644 web/js/map-editor.js create mode 100644 web/js/morphdom.js create mode 100644 web/js/site.js create mode 100644 web/js/u-macrobars.md create mode 100644 web/js/u-query.js create mode 100644 web/js/visualization.js create mode 100644 web/js/workbench.js create mode 100644 web/lib/components.php create mode 100644 web/lib/experiment.class.php create mode 100644 web/lib/filebase.class.php create mode 100644 web/lib/job.class.php create mode 100644 web/lib/labdb.class.php create mode 100644 web/lib/log.class.php create mode 100644 web/lib/mapcatalog.class.php create mode 100644 web/lib/maps.class.php create mode 100644 web/lib/parametercatalog.class.php create mode 100644 web/lib/profiler.class.php create mode 100644 web/lib/results.class.php create mode 100644 web/lib/ulib.php create mode 100644 web/lib/url.class.php create mode 100644 web/server/schema.sql create mode 100644 web/server/worker.php create mode 100644 web/tests/bootstrap.php create mode 100644 web/tests/components/callable-fixture.php create mode 100644 web/tests/components/multi-fixture.php create mode 100644 web/tests/exoplasim-workbench-smoke.php create mode 100644 web/tests/filebase-path-guard.php create mode 100644 web/tests/smoke.php create mode 100644 web/themes/common/page.blank.php create mode 100644 web/themes/common/page.json.php create mode 100644 web/themes/exoplasim/css/map-editor.css create mode 100644 web/themes/exoplasim/css/style.css create mode 100644 web/themes/exoplasim/css/visualization.css create mode 100644 web/themes/exoplasim/page.html.php create mode 100644 web/views/api/index.php create mode 100644 web/views/elements/error.php create mode 100644 web/views/index.php diff --git a/LOCAL.md b/LOCAL.md index fd264b1..2fd32b7 100644 --- a/LOCAL.md +++ b/LOCAL.md @@ -2,7 +2,7 @@ This repository preserves the complete ExoPlaSim upstream Git history and tracked source tree. It adds SBDART and local setup scripts. The upstream README remains unchanged. -The private project repository is `ssh://git@git.openfu.com:11622/udo/exoplasim.git`. The initial working copy is `/root/projects/exoplasim` on `udo-aiworker`. +The private project repository is `ssh://git@git.openfu.com:11622/udo/exoplasim.git`. The working copy is `/root/src/exoplasim` on `udo-aiworker`. The PHP workbench guide is [docs/web-ui.md](docs/web-ui.md). ## Sources diff --git a/config/web.json b/config/web.json new file mode 100644 index 0000000..8c6bead --- /dev/null +++ b/config/web.json @@ -0,0 +1,30 @@ +{ + "base_path": "/exoplasim/web/", + "public_root": "/root/projects/exoplasim/web", + "workspace": "local", + "storage": { + "root": "/var/lib/exoplasim-web", + "database": "data/lab.sqlite3", + "jobs": "jobs", + "runtime": "runtime", + "catalog": "catalog/parameters.json" + }, + "python": ".venv/bin/python", + "worker_user": "exoplasim", + "worker_memory_bytes": 3221225472, + "worker_nice": 10, + "job_timeout_seconds": 3600, + "poll_seconds": 2, + "run_defaults": {"cycles": 1, "steps": 1440}, + "queue_limit": 100, + "minimum_free_bytes": 1073741824, + "max_result_bytes": 1073741824, + "max_field_memory_bytes": 67108864, + "limits": { + "resolutions": ["T21", "T42", "T63", "T85"], + "max_layers": 50, + "max_cycles": 100, + "max_steps": 100000, + "max_cpus": 1 + } +} diff --git a/deploy/exoplasim-worker.service b/deploy/exoplasim-worker.service new file mode 100644 index 0000000..0b96609 --- /dev/null +++ b/deploy/exoplasim-worker.service @@ -0,0 +1,26 @@ +[Unit] +Description=ExoPlaSim job worker +After=local-fs.target + +[Service] +Type=simple +User=@WORKER_USER@ +Group=www-data +WorkingDirectory=@PROJECT_ROOT@ +Environment=EXOPLASIM_CONFIG=@CONFIG_FILE@ +ExecStart=@PHP_CLI@ @PROJECT_ROOT@/web/server/worker.php +Restart=on-failure +RestartSec=3 +UMask=0007 +MemoryMax=@MEMORY_MAX@ +CPUQuota=@CPU_QUOTA@% +Nice=@NICE@ +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=@JOBS@ @RUNTIME@ @DATABASE_DIR@ +ReadOnlyPaths=@PROJECT_ROOT@/web/server @PROJECT_ROOT@/scripts/web + +[Install] +WantedBy=multi-user.target diff --git a/docs/web-ui.md b/docs/web-ui.md new file mode 100644 index 0000000..4d4cc48 --- /dev/null +++ b/docs/web-ui.md @@ -0,0 +1,89 @@ +# ExoPlaSim web UI + +The workbench is single-user. It opens at the project root without a sign-in page. All requests use the workspace name from `config/web.json`. The application does not use sessions, CSRF checks, or network-access gates. + +## Network access + +This is a private development toy. It has no accounts or access-control flow. + +The application uses the existing PHP hosting setup. It needs no application-specific nginx configuration, locations, aliases, rewrites, or protocol redirects. HTTP works directly. Use HTTP on this host. Its shared HTTPS server currently serves PHP as text and does not run the workbench. This host issue is separate from the application. + +Keep the repository outside the webroot. The installer publishes only an ordinary PHP entry file and browser assets to `public_root`. Configuration, simulator sources, tests, Git history, and runtime data are not public files. + +## Install + +1. Place the checkout outside the host's webroot. +2. Run `scripts/install-web --dry-run` to review the configured public and private paths. +3. Run `scripts/install-web` to publish browser assets, generate the catalog, and install the PHP worker unit. +4. Run `systemctl status exoplasim-worker.service` to check the worker. + +The installer preserves job data. It does not create account storage. It copies the model package into the runtime tree, where only the worker account can write. It does not change ownership of the source tree. PHP is the queue worker. Python runs only science command-line tasks. The installed worker is active and enabled at boot. The configured memory and CPU limits protect the shared host. CPU quota follows `limits.max_cpus`; memory and process priority follow `worker_memory_bytes` and `worker_nice`. Large models can exceed these limits. A second installer run preserves the worker PID when the unit does not change. + +## API + +GET and POST requests use ordinary query parameters. Saves and map uploads do not require cookies or special security headers. + +JSON responses use `{ "ok": true, "data": ... }` or `{ "ok": false, "error": ..., "errors": ... }`. The frame route returns little-endian float32 bytes and an `X-Frame-Shape` header. + +| Route | Response data | +|---|---| +| `?api=schema` | Parameter catalog | +| `?api=experiments` | Experiment list or saved experiment | +| `?api=experiment_delete` | `true` after delete | +| `?api=jobs` | Job list or queued job | +| `?api=cancel`, `?api=retry` | Updated job | +| `?api=log&id=…` | `{text, offset, next_offset}` | +| `?api=results&id=…` | `{index, dataset, datasets, latest, downloads}` | +| `?api=series&id=…` | `{time, time_unit, mean, min, max, unit}` for the selected level | +| `?api=frame&id=…` | One bounded spatial frame as binary data | +| `?api=download&id=…&artifact=…` | An approved log, field, raw NPZ and metadata, dataset index, model config, restart, terrain recipe, or SRA file | +| `?api=generate` | `{job}` for a planet-generation job | +| `?api=maps&resolution=…` | Saved map versions and grid; POST saves an immutable float32 map | +| `?api=map&id=…` | Saved map bytes with shape and metadata headers | + +The parameter catalog is the only validation rules source. The installer generates the catalog with `scripts/web/parameters.py`. PHP rejects saves when the catalog is missing or invalid. PHP starts science command-line tasks only through the queue worker. + +## Input maps + +The editor supports height in metres, a binary land-sea mask, land roughness in metres, and broadband land albedo from 0 to 1. Their native contracts are defined in `scripts/web/mapcatalog.py`. Temperature and rainfall results are not editable inputs. Broadband albedo does not replace the separate two-band albedo inputs. + +Saved versions and queued job captures are immutable. Retry uses the original captures, not the current experiment. The worker converts height to land-masked geopotential with the selected gravity. Other maps retain their native units. Roughness and albedo files are placed after model configuration, because that wrapper deletes SRA files when height or land inputs change. The original and converted inputs remain available as downloads. + +A blank step count runs a full model year. Custom step counts retain native output samples instead of annual twelve-bin averaging. Snapshot cadence is capped at the run length, so a short run can finish with readable snapshots. + +## Output views + +Select **Temperature / rainfall / elevation** in the results View menu to show an RGB composite. Red shows surface temperature in kelvin. Green shows total water-equivalent precipitation in mm/day, including snow. Blue shows elevation in metres, calculated from surface geopotential with the saved job's gravity. The legend names this green channel **Precipitation**, not rain alone. + +Each channel uses its own dataset-wide minimum and maximum. The scales stay fixed during playback. The inspector shows all three physical values. The global-mean chart shows temperature. The composite is available only when all three compatible native fields are present. Both the flat map and globe use the same RGB values without globe lighting altering the composite colors. + +## Checks + +Run these checks after a source change: + +```bash +php tests/web-http.php +php tests/web-api.php +php -d zend.assertions=1 -d assert.exception=1 tests/web-backend.php +php tests/web-worker.php +php web/tests/exoplasim-workbench-smoke.php +php web/tests/filebase-path-guard.php +php web/tests/smoke.php +.venv/bin/python -m unittest discover -s tests -p 'test_web_*.py' +php tests/web-public.php +node tests/web-map-brush.js +node tests/web-composite.js +python3 tests/web-installer.py +``` + +Direct HTTP checks return 200 for the workbench, schema, and browser assets without a protocol redirect. Private configuration and server-schema URLs return 404 because those files are outside the webroot. VM 101 opens the HTTP workbench with 93 parameters, existing jobs, and the completed edited planet. No application-specific nginx configuration remains. + +## Current limits + +VM 101 displays completed T21 climate runs. Earlier 1440-step runs have 85 spatial fields and 12 postprocessed time samples. The completed four-map, 240-step run has one native output time sample with finite temperature. Its native land mask matches the generated surface. The browser fetched the raw model NPZ and downloaded the complete terrain recipe. Seeded previews retain the generator version, sampled mass, resolved parameters, and relative surface file names. Bathymetry is the nonpositive part of elevation, in metres. Earlier generator previews have incorrect bathymetry units and must not be used for bathymetry analysis. + +Browser checks cover save, clone, export, import, delete, cancellation of a real simulator, retry after the source preset changes and is deleted, and worker restart recovery. Edits made while a save is pending remain unsaved and are not replaced by the response. The software globe works when WebGL is unavailable. Installation holds a runtime lock while it copies files. The worker cannot claim a new job during this period. Installation refuses queued or running jobs, even when the service is stopped, and restarts the worker to load PHP changes. + +Terrain and planet-parameter generation are repeatable from the saved seed. Native climate runs retain the upstream initial-noise behavior, which seeds its generator from the clock by default. Identical parameter sets do not therefore guarantee byte-identical weather output. Native diagnostics and restart files remain in the job directory. + +Whole-model scientific acceptance remains serial T21 with ten layers. Other resolutions, MPI, long climate statistics, and GPU acceleration are not accepted. Browser checks also cover playback stopping on dataset changes, touch-value inspection, map undo returning to a clean state, recovery from a map-load failure, and software fallback after context-loss events. Physical GPU context recovery remains untested. Final desktop, mobile, source, and failure checks passed. Repository delivery and clean-checkout verification remain open. diff --git a/docs/work/2026-10-10/gui-editor-tests.md b/docs/work/2026-10-10/gui-editor-tests.md new file mode 100644 index 0000000..2be2699 --- /dev/null +++ b/docs/work/2026-10-10/gui-editor-tests.md @@ -0,0 +1,15 @@ +# Map editor browser checks + +`tests/web-gui-editor.js` is an async expression for the existing Chrome page. It requests only `?api=schema`, creates an editor in a temporary owned root, and uses mock source, load, and save callbacks. It removes the root in `finally`. It does not patch application globals or change saved maps, presets, jobs, or experiment state. + +The checks cover keyboard paint, undo to clean, redo, categorical Ocean and Land values, fractional radius input, save callback failure and success, source-load failure and recovery, and cleanup after a held load callback. Callback save success does not send an HTTP mutation. The T85 timing repeats synchronous paint and editor drawing 40 times. It does not measure compositor presentation. + +Run the script from the repository root on the existing VM 101 page. Keep the page target fixed. Use a finite shell timeout. + +```sh +/root/scripts/proxmox/agent-browser.py --vm 101 --target-id FCAA74DC68064807D3663C0A867019EA eval "$(python3 -c 'from pathlib import Path; print(Path("tests/web-gui-editor.js").read_text().strip())')" +``` + +The first run exposed two harness issues. A paint at the default cursor could leave the all-zero map unchanged, so the test now moves the cursor first. Changing a select to its current value does not dispatch its change handler, so the stale-load check changes the field. The editor close guard only blocks pending saves and dirty maps. The test checks that a pending source load disables save, then destroys the editor and releases its held callback. + +`node --check tests/web-gui-editor.js` passed. The VM 101 run passed all 19 assertions. T85 synchronous paint and draw time was 69.40 ms for 40 repetitions on the schema's 32 by 64 grid. This result excludes compositor presentation. The script does not test WebGL, the application visualizer, or mobile workflows. diff --git a/docs/work/2026-10-10/live-http-tests.md b/docs/work/2026-10-10/live-http-tests.md new file mode 100644 index 0000000..0562cd9 --- /dev/null +++ b/docs/work/2026-10-10/live-http-tests.md @@ -0,0 +1,23 @@ +# Live HTTP acceptance + +Ran the stdlib Python client against `http://10.4.2.108/exoplasim/web/` on 2026-10-10. The client uses ordinary HTTP requests, refuses redirects, uses an eight-second request timeout, and stops at 40 requests. It uses CookieJar and checks the HTTP or TLS CSRF cookie name from the URL scheme. + +Command: + +```sh +python3 tests/web-live-http.py --url http://10.4.2.108/exoplasim/web/ +``` + +Result: + +```json +{"map_field": "height", "map_grid": [32, 64], "map_version_ids_persist": ["47a7bd3849a6e98074f4dfbf441c3173", "ca4f988510f3beb6bb0cff20277f7204"], "ok": true, "preset_fixture_ids_deleted": ["c5411bddb2bbc4a41f1bb5d5ea339c0f", "be2d642e96e9112fbfef8e2d339f7b2a", "0461b96d5ba746bd23a9eaa2076d9d47"], "requests": 28, "resolution": "T21"} +``` + +The harness checked direct HTTP 200 without redirect, bootstrap and schema data, CSRF and Origin rejection, unknown operation 404, preset save/list/reload, clone, export-equivalent JSON and import, and validation failure without changing the saved preset. It tested binary map size, range, nonfinite values, invalid resolution, immutable distinct map IDs, map list state, and exact bytes and shape after retrieval. It made no job or simulator request. + +It deleted all three uniquely named preset fixtures. The first run's map IDs were `47a7bd3849a6e98074f4dfbf441c3173` and `ca4f988510f3beb6bb0cff20277f7204`. The verification run's map IDs were `07ecfc2d02db918060b966e9ef3689a0` and `9b45604b0ebe627716f739dafe37e725`. All four immutable map versions remain in the live application. The first run's deleted preset IDs were `c5411bddb2bbc4a41f1bb5d5ea339c0f`, `be2d642e96e9112fbfef8e2d339f7b2a`, and `0461b96d5ba746bd23a9eaa2076d9d47`. The verification run's deleted preset IDs were `cad0a8252c3c6d7439dc0a7d5256145d`, `4adaeeba6479f0646d7c4b2a7788f1e7`, and `59faba45cd9cb262c27bacf0870cb08a`. No queue cancellation test ran because that would need a controlled queued fixture and could affect live queue state. The harness does not yet test binary categorical values, map identifier bounds, or additional resolution bounds. It does not verify trusted-source-IP rejection from a disallowed client. AGX subscription failed with HTTP 404 for task `2026-10-05-ecdc791faf787633e16022ddc66f746`. + +## Later requirements and checks + +Udo later removed accounts, sessions, CSRF checks, and network gates from this private dev app's requirements. The security behavior described above is historical, not current configuration. The harness now sends cookie-free requests and tests categorical map values and identifier bounds. Its final run passed 22 requests and removed all preset fixtures. Current behavior is documented in `docs/web-ui.md`; later acceptance is in `docs/work/2026-10-11/final-ui.md`. diff --git a/docs/work/2026-10-10/rgb-output.md b/docs/work/2026-10-10/rgb-output.md new file mode 100644 index 0000000..19ed48e --- /dev/null +++ b/docs/work/2026-10-10/rgb-output.md @@ -0,0 +1,19 @@ +# RGB output view acceptance + +Udo requested a map that combines temperature, rainfall, and elevation as independent color channels. + +The results View menu now provides this composite on the flat map and globe. Red uses native surface temperature (`ts`, K). Green uses native total water-equivalent precipitation (`pr`), converted from m/s to mm/day. This field includes snow; its legend says Precipitation. Blue uses native surface geopotential (`sg`), divided by the immutable job's gravity to obtain metres. Each channel has an independent, fixed dataset-wide scale. The inspector shows the three physical values. The chart is identified as global mean temperature. Globe lighting does not alter composite colors. + +A real two-cycle run, job `52e41e627d70345d3cc414ce6a458f67`, completed. The GUI exposes Cycle 1 and Cycle 2 as separate datasets. For Cycle 2 (`826305a1ca59901cd49341a3375697e1`), the saved gravity is 12.616504674551006 m/s². VM 101 fetched the three native float32 frames and compared a canvas pixel with the calculated channel intensities. Expected RGB was `[202, 28, 72]`; the displayed pixel was `[202, 28, 72, 255]`. This checks the actual output and a non-Earth gravity, not fabricated climate fields. + +Screenshots `tmp/rgb-map-desktop.png` and `tmp/rgb-globe-mobile.png` show the composite. The mobile screenshot uses 390 × 844 pixels. The three legends and physical inspector values are visible without horizontal page overflow. VM 101 uses the software globe; physical WebGL rendering remains unverified. + +`tests/web-composite.js` checks conversions, separate scales, constant fields, nonfinite values, incompatible geometry, missing fields, and invalid gravity. `tests/web-gui-composite.js` checks three legends, unchanged scales during playback, converted inspection, a precipitation-request 500, recovery, and return to the single-field view. Its synthetic T85 fixture contains 32,768 cells; these are renderer test values, not climate output. + +Forty synthetic fetch/decode/render samples initially averaged 14.66 ms, with p95 19.80 ms and maximum 21.10 ms. Computing each native cell's RGB once, then copying rows into the latitude raster, reduced the mean to 7.92 ms, p95 to 15.40 ms, and maximum to 19.50 ms. Timing ends at the completion DOM mutation after synchronous drawing. It does not include browser compositing or real HTTP latency. + +The final suite passed 20 Python tests, eight PHP checks, both Node brush and composite checks, and 22 live HTTP requests. A real twelve-time annual dataset advanced to time index 2 in RGB playback; all three scales stayed fixed and the frames loaded without horizontal overflow. + +Source review also found two unrelated UI races. A delayed dataset response could replace a newer dataset or a different job's visualization. Dataset requests now retain their job and visualization instance and reject stale responses. A VM 101 test delayed Cycle 1, selected Cycle 2, then repeated the delay while selecting a different job. The latest selection remained intact. A failed schema request also left Refresh unable to restore the editor. An injected startup failure reproduced this; Refresh now restores the resolution choices and all 93 parameters without duplicate polling timers. + +Successful saves and queue operations now use their mutation response to update the local lists. A later collection refresh is no longer part of the reported save or queue result. Verification of these response-cache changes remains part of the wider UI acceptance work. diff --git a/docs/work/2026-10-10/web-acceptance.md b/docs/work/2026-10-10/web-acceptance.md new file mode 100644 index 0000000..21a4782 --- /dev/null +++ b/docs/work/2026-10-10/web-acceptance.md @@ -0,0 +1,77 @@ +# Web acceptance evidence + +## 21:30 UTC + +- JavaScript syntax checks passed for the workbench and renderer. +- PHP API, backend, worker, and live HTTP checks passed. All 19 Python web tests passed in 2.693 seconds. +- The browser fetched the completed climate NPZ: HTTP 200, attachment header, 18,732,347 bytes, ZIP signature. +- Changing the climate field to `ta` exposed ten levels. +- A delayed save retained new editor input: the server saved `startemp=6000`, the editor kept `6100`, and the UI reported that current edits were not saved. +- Clone retained the original value `6100` and saved `5900` in the copy. Export and import retained `5900`. Export contained only `name` and `spec`. +- Fixed the nullable Default control, stale validation messages, import object checks, and invalid-field state after New or Reset. The last import and reset changes still need browser regression checks. +- Fixed generated bathymetry. It was unscaled terrain noise incorrectly labelled metres. It is now the nonpositive part of metre-valued elevation. A regression checks exact equality. Added pole and longitude-seam checks. Generator version changed; native land and topography inputs are unchanged by this fix. +- Generated seed 21 through the UI: job `89ff167e50a5b0b4d064e66a57a3f89a`, dataset `6a49f8e9f04356cc356701f381a9b30f`. The downloaded recipe contains generator version, sampled mass, resolved recipe, actual land fraction, and relative surface names. +- Cancelled real model job `f3c27280b11a80c5b0b1591b1aa05ff3`. Its state became cancelled and its PID cleared. +- Changed its source preset from `startemp=5900` to `5800`, then deleted that preset through the UI. Retry `6a6d1d6bfff6c1a849ce6ab92fc9f157` retained the original `5900`, model kind, and two cycles. +- Before restart, the retry had scientific Python and native Fortran children in one process group. Restarting the owned worker stopped both children. The retry became interrupted with `Worker stopped before job completion`. +- Installed configurable worker resource limits. Live systemd properties show MemoryMax 3221225472, CPUQuotaPerSecUSec 1s, Nice 10, active and enabled. A second install exited zero and retained PID 845711. +- Installer now uses the shared PHP configuration resolver and configured storage paths. Removed its duplicate nginx include insertion block. +- Captured backend and HTTP delegate results, then closed their panes. The starter cleanup delegate remains open for final test and inventory corrections. + +No whole-objective completion is claimed. Earlier generator previews still contain the bathymetry defect. Playback, touch, WebGL recovery, installer protection of active jobs, and final source cleanup remain under acceptance. + +## Additional user requirement + +Udo requires editing a planet's height map and other maps. This extends the active objective. The editor must save real simulator input maps, not only change the display. Establish the native input-map contracts before selecting additional editable fields. Include brush controls, undo and redo, saved map versions, and immutable map snapshots for queued runs. Preserve the original generated surface and distinguish input maps from simulation outputs. Test edited height and land maps through a real native run. Keep all integration and persistence in PHP, with scientific conversion outside HTTP requests. + +## 22:05 UTC + +- Implemented the height and land-mask editor with geographic brushes, seam and pole handling, visible brush outlines, undo, redo, keyboard controls, touch painting, and immutable saved versions. +- VM 101 loaded seed 21 terrain. Set height at 2.77°N, 0° to 2000 m. Undo restored -1711.96 m. Redo restored 2000 m. Land painting changed the same cell from ocean to land. +- Saved height version `1ef0fd5583c83a03fe4de54c721e1a57` and land version `7f94a0e9805ca99f2b18a37ef7dfbdc9`. Saved experiment `f1a464a46d4ecddafb82256ab0020a85` contains both references. +- Native model job `978129d60db6a2f9868f5c4269ded079` completed with the edited snapshots. Exact native-input and output comparisons remain pending. +- Mobile touch painting set a cell to 2500 m. Undo restored 946.786 m. At a 390-pixel viewport, the editor had no horizontal overflow. +- A T85 brush test used 32,768 cells. Forty dabs averaged 1.255 ms, with a 6.60 ms maximum. These are brush-kernel measurements, not full-frame rendering measurements. +- Removed all application-specific nginx locations, aliases, rewrites, and redirects after Udo rejected that design. Removed both includes from the existing host config and deleted the application snippets and deployment template. Other host configuration remains unchanged. +- Moved the private checkout to `/root/src/exoplasim`. The public directory contains only an ordinary PHP entry, JavaScript, and CSS. Runtime data remains outside the webroot. +- Rewrote the installer to publish public files and install the PHP worker without nginx operations. The worker now runs from the private checkout. Updated the local editable-install path after relocation. +- HTTP workbench, schema, and browser assets return 200 without redirects. Private configuration and server-schema URLs return 404. The effective nginx configuration contains no ExoPlaSim rules. +- Moved trusted-network enforcement into PHP. IPv4, IPv6, exact-address, partial-prefix, and denied-address tests pass. Client forwarding headers do not control access or the request scheme. +- Separated HTTP and TLS CSRF cookie names. This fixes the browser's refusal to replace an old Secure cookie after the protocol change, without deleting another application's cookies. +- VM 101 now opens the HTTP workbench without a TLS interstitial. It shows 93 parameters and existing results. The browser queued post-migration native job `f25e1b2ae4a0bf02bc7f677825aa20ed` through the real CSRF-protected interface. +- Public-file, network, API, backend, worker, and brush checks pass. All 20 Python web tests pass in 2.695 seconds. +- Captured the map backend delegate's results and closed its pane. All delegates started for this objective are closed in the actual tmux pane list. + +Post-migration job `f25e1b2ae4a0bf02bc7f677825aa20ed` completed. Its captured cell is 2000 m and land mask 1. The native land SRA matches the captured array exactly. Height conversion matches the land-masked elevation times selected gravity within 0.005512 m²/s² ASCII rounding error. The final NPZ land mask matches the edited array exactly, and surface temperatures are finite. Native SRA files now remain in the job inputs directory outside model runtime cleanup. The browser downloaded the retained height SRA through ordinary HTTP with status 200 and code 129, 64 longitudes, and 32 latitudes. + +Remaining acceptance includes map-editor full-frame timing, WebGL recovery, final source review, and repository delivery. No whole-objective completion is claimed. + +## User scope correction: private dev toy + +Udo explicitly removed the previous CSRF and trusted-network requirements. Removed those checks, Origin and Fetch Metadata gates, session startup, cookie configuration, bootstrap token, frontend security headers, network class/tests, and unused configuration/installer paths. The application now uses ordinary cookie-free GET and POST operations. No TLS, routing, or host configuration changes accompanied this correction. + +Real HTTP save and delete both returned 200 without cookies or special headers. Neither response sets a cookie. Root HTML and headers contain no CSRF token or Set-Cookie. PHP API/backend/worker tests pass. The live HTTP test delegate is updating its tests to the new requirement. Scientific validation and immutable run inputs remain part of program functionality. + +A separate shared HTTPS-host issue was reported to Cheems Telegram, message 7310. No further shared-host remediation is part of this app task. + +## Four editable inputs and short-run acceptance + +Added land roughness (native code 173, metres) and broadband land albedo (code 174, dimensionless). Native defaults are 2 m and 0.22. Albedo conversion writes the same map in all 14 climatology slots. The native reader supports this contract. Separate two-band albedos are not replaced. + +Source review found that resource names must match the native grid/code names and that model configuration removes SRA files when height or land maps are set. Corrected the managed copy order and names. The first four-map queue attempt, `36be7bde559e75cb86cbf180c0bc61df`, exposed a separate map-capture bug: SQL sorted fields but validation compared JSON key order. Validation now compares the field set. A reversed-key regression passes. + +Retry `989fa1561902c703a689879c84f664f3` exposed short-run postprocessing failures. Native output was finite, but annual twelve-bin averaging turned its one sample into NaNs. The snapshot interval exceeded the 240-step run and produced an unreadable single initial snapshot. Custom runs now retain native time samples and cap snapshot cadence at run length. Full-year settings are unchanged. Focused regressions cover default, disabled, shorter, and longer snapshot cadence. + +GUI retry `66dc2fbc1f7b1ca15268e8d42c70199b` completed with all four original captures. Dataset `e28d4457977a41f9f9710deb091ef308` contains one native time, 159 timesteps, and finite surface temperature from 243.28078 to 284.78146 K. Captured roughness at 2.77°N, 0° is 5.4321365 m; native output is 5.432137 m. Captured albedo is 0.150000006; native surface albedo is 0.1500983 after model processes. Retained SRA values match captured maps within their six-decimal scientific-format precision. Height and land-mask captures remain the earlier edited versions. + +## Rendering and interaction checks + +VM 101 T85 editor timing measured the complete synchronous keyboard-paint path: brush, history, raster, inspection, and controls. Forty paints over 32,768 cells averaged 6.93 ms, p95 15.4 ms, max 18.5 ms. Reusing the raster buffer, caching latitude rows, and removing per-pixel color-array allocation reduced this to mean 4.54 ms, p95 7 ms, max 13.7 ms. These timings do not include browser compositing. T21 averaged 1.87 ms, max 3 ms. + +GUI checks confirmed playback advances on the earlier multi-time dataset and stops when selecting the one-time result. The Play button returns to unpressed and disabled. Context-loss/restoration events leave the software globe usable without disabling its mode button. This tests event recovery, not a physical GPU context. + +Job polling retains keyboard focus and does not replace unchanged rows. Returning an albedo edit to its original value with Undo disables Save and clears the dirty state. An injected map-load 500 clears the previous legend and inspector, disables Save, and permits recovery by choosing a uniform map. Loaded fields now replace the stale Loading inspector message. Touch release inspects a value on maps and globes. + +Screenshots `tmp/four-map-globe-desktop.png` and `tmp/four-map-globe-mobile.png` show the real four-map result. Neither layout has horizontal page overflow. The screenshot exposed the stale Loading inspector text, which was then fixed. A slow-load check then confirmed that Close works while a map is loading. Closing destroys the editor; the delayed response cannot recreate its controls. Final screenshots remain pending. + +The current cookie-free live harness passed 22 requests and deleted all three preset fixtures in a finally block. Its two retained map IDs are `7d5d8ad6739b9aac6b7dbb8cfeb82586` and `25d7a8f12162aa7c9b24ab52fffc242f`. Main also removed ten failed-run preset fixtures created by the earlier delegate between 22:12 and 22:16 UTC. No unrelated presets were removed. Both delegates are closed; actual tmux panes were checked. diff --git a/docs/work/2026-10-11/final-ui.md b/docs/work/2026-10-11/final-ui.md new file mode 100644 index 0000000..571ab31 --- /dev/null +++ b/docs/work/2026-10-11/final-ui.md @@ -0,0 +1,26 @@ +# Final UI acceptance + +Authoritative source is `/root/src/exoplasim`. The public directory contains only the PHP entry and browser assets. This remains a private, cookie-free PHP workbench with no accounts, sessions, CSRF checks, network gates, or application-specific web-server configuration. + +## Changes and checks + +- VM 101 ran `tests/web-gui-workbench.js`. Three saved preset fixtures exercised response caching, deletion after another preset was selected, and edits made during deletion. All checks passed. The fixtures were removed. Its queue-response case was mocked and did not create a scientific job. +- Parameter search for gravity previously showed one field and ten group headings. It now shows one field in one group, with a count of 1 / 93. +- Installation now checks queued and running jobs without relying on systemd service state. A shared runtime lock prevents a worker claim during file replacement. The installer restarts the worker so PHP changes take effect. Three isolated installer tests cover dry-run behavior, both active states, and concurrent installation. The worker test covers claim exclusion and later release. +- A repeat installation preserved all nine saved experiments, thirteen jobs, fourteen map versions, twenty captured maps, and seven result manifests. These are the counts at that test's snapshot, not current configuration. +- Failed job `36be7bde559e75cb86cbf180c0bc61df` has an empty log. Its stored map-capture error now appears in the UI. Selecting a healthy job clears the error. +- Native keyboard entry of `1e` previously marked nullable gravity as Default without an error. It could also turn an incomplete custom step count into a full-year run. The UI now keeps invalid values unsaved. `tests/web-gui-numbers.py` used real CDP keyboard input for gravity and run steps. Both checks passed with zero persistence requests. +- Download lists now contain the selected dataset's fields and raw output. The API no longer opens every other cycle's index and checks all its field files. Direct older-cycle download URLs still work. The PHP artifact test covers both behaviors. +- A real two-cycle GUI check started on Cycle 2 and selected Cycle 1. The raw-output menu changed to the matching dataset. The selected `ua` field and level 3 remained selected, and the frame loaded. + +## Runtime verification + +After installation, VM 101 queued job `dab2d04187faa012e76a18e84ab66cad` while job-list requests deliberately failed. The mutation response selected the job and reported queue success. The unprivileged worker completed its 240-step, four-map run and published dataset `856a855c769e3ff47870497b02bdd985`. The browser loaded the result and hid the prior failure message. Its temporary preset was removed. The job and immutable inputs remain. + +An extra comparison initially assumed this run would match the earlier four-map run byte for byte. That assumption was wrong. The specs, native namelists, input SRA files, and simulator executables match. The surface-geopotential frame also matches. Temperature and precipitation differ slightly. + +Native diagnostics explain the difference. Both runs use the upstream default `SEED=8*0`. `plasim.f90` then seeds the initial-noise generator from `system_clock`, and the recorded seed vectors differ. No simulator or precision change was found. This preserves upstream behavior. Terrain and sampled planet parameters are reproducible from their saved seed, but unseeded climate runs do not promise identical weather trajectories. The canonical guide states this limit. + +## Remaining delivery + +Run the final complete suites and browser fixtures against the latest source. Capture the final desktop and mobile views. Commit and push the workbench and its documentation. Verify a clean checkout using the pinned scientific environment. Physical GPU recovery, MPI, and long-term climate statistics remain outside the accepted serial T21 UI scope. diff --git a/docs/work/web-ui.md b/docs/work/web-ui.md new file mode 100644 index 0000000..888c18a --- /dev/null +++ b/docs/work/web-ui.md @@ -0,0 +1,147 @@ +# ExoPlaSim web UI + +## Objective and invariants + +Build a usable scientific workbench from the maintained PHP web-app-starter. Remove demo content. Manage parameters, background runs, logs, and real spatial output. + +Keep the Fortran simulator and checked build. PHP owns HTTP handling, queue management, and background-job control. Python programs are scientific executables called by PHP, not server implementations. Do not run simulation or result extraction in HTTP requests. Support relative filesystem paths. All application operations use ordinary query parameters through the PHP front controller. PHP reads `$_GET` and `$_POST`; do not parse URL locators or paths. Do not add custom servers, CGI endpoints, or a separate routing layer. Preserve truthful saves, immutable job inputs, cancellation, and outputs. Use one workspace with no accounts, sign-in, or logout. Keep runtime data outside the served source tree. Use starter shell utilities and components. Do not use application path routing. Do not add external services or automation. + +## Success criteria + +- [x] The browser opens the single-user workspace directly. It creates, edits, clones, exports, and queues a parameter set. +- [x] An unprivileged worker runs the real simulator and survives request termination. +- [x] The browser shows queue state, progress, bounded logs, failures, cancellation, and retry. +- [x] Real output supports field, time, and level selection, map/globe rendering, charts, and downloads. +- [x] No accounts, sessions, CSRF checks, network gates, TLS requirements, or application-specific server configuration remain. +- [ ] Setup, service files, tests, and current documentation ship in the project repository. +- [x] The user edits height, land mask, land roughness, and broadband land albedo; saved versions feed real runs through immutable captures. + +## Current state + +Status: executing. + +Source: project `web/`, `web/server/`, `config/web.json`, and `scripts/`. Runtime uses the host's ordinary PHP setup. The private checkout is outside the webroot. `public_root` contains only the PHP entry and browser assets. No application-specific nginx configuration, redirects, aliases, custom servers, or CGI endpoints are permitted. + +The starter import is the clean maintained local commit `1a246ce4550a0b63dedaefbc62ef7946c4000b2d`. Keep the maintained componentized shell and record provenance. The imported `.git`, logs, agent state, and code-search tools are excluded. + +## Goal tree + +- [x] G1: A clean starter-derived application shell works. + - [x] G1.1: Import the maintained starter and record its source. + - [x] G1.2: Remove demos, unused themes/assets, and unused broker tooling. + - [x] G1.3: Preserve starter shell utilities and component dispatch. Do not use sessions or security gates in this private dev app. +- [x] G2: Scientific parameter sets are reproducible. + - [x] G2.1: Generate a parameter catalog from the installed Model signature and scientific docstrings. + - [x] G2.2: Save, clone, import, export, and validate parameter sets. + - [x] G2.3: Snapshot validated inputs into immutable jobs. +- [x] G3: Background jobs have reliable lifecycle management. + - [x] G3.1: PHP persists the queue in SQLite and claims jobs atomically. + - [x] G3.2: An unprivileged PHP worker calls the scientific executables with finite limits. + - [x] G3.3: Publish phase/progress/logs and support cancel, retry, and interrupted-run recovery. +- [x] G4: Model output is useful in the browser. + - [x] G4.1: Extract NPZ output into indexed binary fields and bounded statistics. + - [x] G4.2: Render real flat maps and interactive globes with time/level/field controls. + - [x] G4.3: Provide global-mean charts, data inspection, and downloads. +- [x] G6: The user can generate a random exoplanet. + - [x] G6.1: Use a saved seed so generation is repeatable. + - [x] G6.2: Generate a spherical elevation map and a matching land/ocean mask. + - [x] G6.3: Provide controls for land fraction, terrain relief, and geographic scale. + - [x] G6.4: Generate compatible planet, orbit, star, and atmosphere parameters. Keep them editable before a run. + - [x] G6.5: Preview the generated surface on a map and globe. Export its recipe and surface files. + - [x] G6.6: Convert the surface to ExoPlaSim inputs at the selected resolution. Test coordinate order and elevation-to-geopotential conversion. +- [x] G7: Editable surface maps feed real simulation inputs. + - [x] G7.1: Store immutable height, land-mask, roughness, and broadband albedo versions through PHP with real Gaussian grids. + - [x] G7.2: Provide geographic brushes, range limits, smoothing, undo, redo, keyboard access, and touch controls. + - [x] G7.3: Start from generated terrain or saved versions. Keep output fields separate from input maps. + - [x] G7.4: Snapshot map versions into each queued job. Verify retry and later edits cannot change those inputs. + - [x] G7.5: Run edited maps through the native model and verify SRA coordinates, gravity conversion, and actual model inputs. +- [~] G5: Deployment and acceptance are repeatable. + - [x] G5.1: Publish public-only assets and an ordinary PHP entry. Keep private source outside the webroot. Install the systemd worker without application-specific nginx configuration. + - [x] G5.2: Test queue concurrency, scientific validation, frame bounds, failures, and ordinary cookie-free saves. + - [x] G5.3: Complete a real browser-to-worker-to-visualization run. + - [x] G5.4: Test relative assets, query operations, APIs, and downloads through the existing PHP front controller. Do not add test servers or CGI mount schemes. + - [x] G5.5: Test relative storage paths and scientific commands from another working directory. + - [x] G5.6: Verify that PHP owns every server lifecycle operation. Remove Python server implementations. + +## Acceptance evidence + +| Requirement | Evidence | +| --- | --- | +| Direct workspace, parameter CRUD, validation, truthful pending saves | `tests/web-http.php`, `tests/web-backend.php`, `tests/web-gui-workbench.js`, `tests/web-gui-numbers.py`, and dated GUI acceptance | +| Immutable specs and native map captures, retry after changes and deletion | Backend and worker tests, retained native inputs, four-map runs in `docs/work/2026-10-10/web-acceptance.md` | +| Independent worker, atomic claims, finite limits, cancellation and recovery | `tests/web-worker.php`, systemd state, real native jobs, and installer lock tests | +| Native fields, units, times, levels, weighted means, bounds and downloads | Python results tests, `tests/web-api.php`, native GUI frames and download checks | +| Reproducible planet generation and compatible native maps | Python planet tests, generated and edited climate runs in dated acceptance | +| Brushes, smoothing, undo/redo, keyboard, touch, saved versions and failure recovery | `tests/web-map-brush.js`, `tests/web-gui-editor.js`, and VM 101 mobile checks | +| RGB temperature/precipitation/elevation view | `tests/web-composite.js`, `tests/web-gui-composite.js`, and `docs/work/2026-10-10/rgb-output.md` | +| Ordinary PHP query operations without security/session machinery | Source audit, cookie-free live HTTP harness, and `tests/web-public.php` | +| Relative paths and repeatable installation without data replacement | Worker/Python fixtures from another working directory, `tests/web-installer.py`, and `docs/work/2026-10-11/final-ui.md` | +| Repository delivery | Pending commit, push, and clean-checkout verification | + +## Shared implementation contract + +`config/web.json` is the shared configuration source. Consumers resolve relative storage keys against `storage.root`. Resolve the Python path against the project root. `EXOPLASIM_CONFIG` can select another configuration file. Do not paste host paths or operational limits into another definition. + +A saved experiment contains `id`, `owner`, `name`, `spec`, `created_at`, and `updated_at`. IDs are 32 lowercase hexadecimal characters. A job snapshots an experiment's name and spec. Editing an experiment never edits an existing job. + +A spec has this shape: + +```json +{"model":{"resolution":"T21","layers":10,"ncpus":1},"parameters":{},"run":{"cycles":1,"steps":1440}} +``` + +Only the constructor fields shown are public. Precision remains FP64. `run.steps` may be null for a full model year, or a bounded integer for an explicit cycle. Do not call a custom-length cycle a year in UI copy. The worker controls model name, source, paths, and executables. Parameters are Model.configure overrides, not a second source of defaults. + +The main worker owns `scripts/web/parameters.py` with `catalog(config)` and `validate_spec(spec, config)`. Validation returns `{ok, spec, errors}`. The cached catalog contains `fields` as an array of `{name,label,type,default,nullable,description,group,editable,choices?}`, plus model/run defaults and limits. Types are `number`, `integer`, `boolean`, `string`, `object`, or `array`. Derive defaults from the signature. Reject paths, scripts, raw namelist injection, and unknown fields. File-backed inputs need managed uploads before they become editable. Control runsteps through the run envelope. All runnable specs require spatial output. + +The main worker also owns `web/lib/parametercatalog.class.php`, with `ParameterCatalog::schema()` and `ParameterCatalog::validate($spec)`, using the same cached schema. Save validation and queue validation must report field errors rather than silently discard input. + +### PHP storage boundary + +The PHP backend owns `web/server/worker.php` and `web/config/lab.php`, which exposes `lab_config()` with resolved keys `database`, `jobs`, `runtime`, `runtime_lock`, `catalog`, `python`, and `project_root`, while retaining other raw config values. + +It owns `web/server/schema.sql` as the shared SQLite DDL, `web/lib/labdb.class.php`, `web/lib/experiment.class.php`, and `web/lib/job.class.php`. `LabDb::connection()` returns PDO. SQLite uses WAL, bounded busy waits, and indexes for ownership, state, and listing. + +Methods return ordinary records or structured results `{ok, data?, errors?, error?}`. No application exceptions or request aborts for expected validation/not-found cases. + +Experiment methods: `all($owner)`, `get($id,$owner)`, `save($owner,$data)`, `delete($id,$owner)`. Deletion affects the saved parameter set, not existing job snapshots. + +Job methods: `all($owner)`, `get($id,$owner)`, `enqueue($owner,$experiment)`, `cancel($id,$owner)`, `retry($id,$owner)`, `log($id,$owner,$offset=0)`, and `directory($job)`. Log reads are incremental and bounded. Listing is bounded. Retry creates a new job with the same immutable inputs. State names are `queued`, `running`, `completed`, `failed`, `cancelled`, and `interrupted`. A running cancellation sets `cancel_requested`; queued cancellation is immediate. + +Jobs expose `id`, `experiment_id`, `name`, `spec`, `status`, `phase`, `progress`, `completed_cycles`, `total_cycles`, `created_at`, `started_at`, `finished_at`, `updated_at`, `error`, and `cancel_requested`. Timestamps use UTC ISO 8601. The worker owns execution PIDs and state transitions. + +### HTTP boundary + +The PHP frontend owns query API operations under `web/views/api/`. It does not use CSRF checks. Use the existing PHP front controller with query parameters, for example `?api=frame&id=…&time=…`. Do not parse route locators or request paths. + +Responses are `{ok:true,data:...}` or `{ok:false,error:...,errors?:...}`. GET operation names are `schema`, `experiments`, `jobs`, `log`, `results`, `series`, `frame`, and `download`. Detail requests use `id`. + +POST `?api=experiments` saves `{id?,name,spec}`. POST `?api=experiment_delete` deletes a saved set. POST `?api=jobs` queues `{experiment_id}` after successful save. POST `?api=cancel` and `?api=retry` take `{id}`. The UI can clone or import through normal experiment save. Downloads and exports use only owned IDs and approved artifact identifiers. + +The application is single-user. It has no accounts, sign-in, or logout. API mutations do not require sessions, CSRF tokens, or network checks. The workbench root carries a bootstrap object `window.EXOPLASIM` with `api` mapping operation names to relative query strings. `config.workspace` identifies the one workspace in storage. Do not add security gates or sessions to this private dev toy. The UI must show request errors and preserve edits. + +### Results boundary + +The visualization implementation is `scripts/web/results.py` with `prepare_results(npz_path, destination, dataset_id)` and a CLI entry point. The worker calls it after each cycle. + +Each job uses `artifacts/datasets//`. A dataset stores `index.json` with `id`, `lat`, `lon`, `time`, `time_unit`, `levels`, and `fields`. Each field is `{name,label,unit,shape:[time,level,lat,lon],min,max}`. Coordinate variables are not fields. Metadata labels and units come from ExoPlaSim NPZ metadata, not an invented registry. + +Each field has `.f32` in little-endian C order and `.json` with `{mean,min,max}` arrays shaped `[time][level]`. Global means use area weights and ignore nonfinite cells. Do not include large per-field statistics in the dataset index. + +`artifacts/index.json` lists datasets as `{id,label}` and identifies `latest`. The PHP worker maintains this job index after the scientific child prepares each dataset. Publish indexes atomically after files are ready. Keep memory and storage bounded by config limits. Preserve NPZ, exported config, logs, and restart files for downloads. + +GET `?api=results&id=...&dataset=...` returns the selected dataset index plus `datasets` and `latest`. GET `?api=series` selects `id`, `dataset`, `field`, and `level`, returning `{time,time_unit,mean,min,max,unit}`. GET `?api=frame` selects those plus `time` and returns one binary float32 spatial frame. Bounds and file names come from the owned manifest. No Python process or full field-file read in a PHP request. + +The visualization delegate owns `web/js/visualization.js`. Expose `window.ExoVisualization` with constructor `(container)` and methods `setDataset(index, frameUrlFunction, seriesUrlFunction, options?)`, `destroy()`. `options.gravity` supplies the saved job's gravity for the RGB elevation channel. It creates its own field/time/level controls, map/globe, legend, inspector, and chart inside that container. URL callbacks take `(field,time,level)` and `(field,level)`. It must handle aborts, stale responses, resize, no WebGL, and missing values. Use local native browser APIs, no CDN or new package installation. + +The frontend delegate owns the workbench component, index view, workbench JS, and the ExoPlaSim theme. Render through `component()` / `component_call()`. Use one clear workspace with experiment selection, parameter editing, jobs, visualization, and logs. No demo data, marketing cards, planning explanations, or unnecessary subheadings. + +## Next + +Finish the final suites and current screenshots. Commit and push the application, configuration, installer, tests, and documentation. Verify a clean checkout without changing the live installation. Earlier acceptance evidence is in `docs/work/2026-10-10/web-acceptance.md`. RGB acceptance is in `docs/work/2026-10-10/rgb-output.md`. + +## Remaining scope and risks + +Random exoplanet generation is part of the requested scope. Generated surfaces must reach the simulator, not remain decorative previews. Describe them as procedural scenarios, not reconstructed planets or geological predictions. + +Managed height, land-mask, roughness, and broadband land-albedo maps are part of the active scope. Restart-from-UI, parameter sweeps, SBDART pipelines, comparative climate analysis, and administrator storage cleanup may need later increments. Do not expose inactive controls or claim these work. Large models can exceed resource limits. Native settings and complete MPI acceptance remain outside this UI slice. diff --git a/scripts/install-web b/scripts/install-web new file mode 100755 index 0000000..be1b240 --- /dev/null +++ b/scripts/install-web @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CONFIG="${EXOPLASIM_CONFIG:-$ROOT/config/web.json}" +[[ "$CONFIG" = /* ]] || CONFIG="$ROOT/$CONFIG" +readarray -t V < <(EXOPLASIM_CONFIG="$CONFIG" php -r ' +require $argv[1]."/web/config/lab.php"; +$c=lab_config(); +foreach([$c["storage_root"],$c["worker_user"],$c["python"],$c["worker_memory_bytes"],$c["limits"]["max_cpus"]*100,$c["worker_nice"],$c["jobs"],$c["runtime"],$c["database"],$c["catalog"],$c["public_root"],$c["runtime_lock"]] as $v)echo $v,"\n"; +' "$ROOT") +DATA=${V[0]}; WORKER=${V[1]}; PYTHON=${V[2]}; MEMORY=${V[3]}; CPU=${V[4]}; NICE=${V[5]} +JOBS=${V[6]}; RUNTIME=${V[7]}; DATABASE_FILE=${V[8]}; DATABASE_DIR="$(dirname "$DATABASE_FILE")"; CATALOG=${V[9]}; PUBLIC=${V[10]}; RUNTIME_LOCK=${V[11]} +[[ "$PUBLIC" = /* ]] || PUBLIC="$ROOT/$PUBLIC" +PHP_CLI="$(command -v php)" +if [[ "${1:-}" == '--dry-run' ]]; then + printf 'Would publish PHP entry and browser assets to %s, install PHP worker from %s, and preserve runtime data in %s. No nginx configuration changes.\n' "$PUBLIC" "$ROOT" "$DATA" + exit 0 +fi +mkdir -p "$DATA" +exec 9>"$RUNTIME_LOCK" +flock -n 9 || { printf 'A runtime installation or job claim is in progress.\n' >&2; exit 1; } +if [[ -f "$DATABASE_FILE" ]]; then + ACTIVE_JOBS=$("$PHP_CLI" -r '$db=new PDO("sqlite:".$argv[1]);echo (int)$db->query("SELECT COUNT(*) FROM jobs WHERE status IN (\"queued\",\"running\")")->fetchColumn();' "$DATABASE_FILE") + if ((ACTIVE_JOBS > 0)); then printf 'Refusing to change runtime files while %s jobs are active.\n' "$ACTIVE_JOBS" >&2; exit 1; fi +fi +mkdir -p "$ROOT/tmp" +UNIT_PATH=/etc/systemd/system/exoplasim-worker.service +sed -e "s|@WORKER_USER@|$WORKER|g" -e "s|@PROJECT_ROOT@|$ROOT|g" -e "s|@CONFIG_FILE@|$CONFIG|g" -e "s|@PHP_CLI@|$PHP_CLI|g" -e "s|@JOBS@|$JOBS|g" -e "s|@RUNTIME@|$RUNTIME|g" -e "s|@DATABASE_DIR@|$DATABASE_DIR|g" -e "s|@MEMORY_MAX@|$MEMORY|g" -e "s|@CPU_QUOTA@|$CPU|g" -e "s|@NICE@|$NICE|g" "$ROOT/deploy/exoplasim-worker.service" > "$ROOT/tmp/exoplasim-worker.service" +getent passwd "$WORKER" >/dev/null || useradd --system --home-dir "$DATA" --shell /usr/sbin/nologin "$WORKER" +install -d -o root -g root -m 0755 "$DATA" +if [[ ! -e "$RUNTIME_LOCK" ]]; then : > "$RUNTIME_LOCK"; fi +chmod 0644 "$RUNTIME_LOCK" +install -d -o "$WORKER" -g www-data -m 0750 "$RUNTIME" +install -d -o www-data -g www-data -m 2770 "$DATABASE_DIR" +install -d -o "$WORKER" -g www-data -m 2750 "$JOBS" +install -d -o root -g root -m 0755 "$(dirname "$CATALOG")" +"$PYTHON" "$ROOT/scripts/web/parameters.py" --config "$CONFIG" --output "$CATALOG" +chown root:root "$CATALOG" +chmod 0644 "$CATALOG" +install -d -o "$WORKER" -g "$WORKER" -m 0750 "$RUNTIME/python" +rsync -a --delete --exclude='.git' --exclude='tmp' "$ROOT/exoplasim/" "$RUNTIME/python/exoplasim/" +chown -R "$WORKER:$WORKER" "$RUNTIME/python/exoplasim" +chmod -R u+rwX,go-w "$RUNTIME/python/exoplasim" +setfacl -m u:www-data:--x,u:"$WORKER":--x /root +install -d -m 0755 "$PUBLIC" +rsync -a --delete --include='*/' --include='*.js' --exclude='*' "$ROOT/web/js/" "$PUBLIC/js/" +install -d -m 0755 "$PUBLIC/themes/exoplasim/css" +rsync -a --delete --include='*.css' --exclude='*' "$ROOT/web/themes/exoplasim/css/" "$PUBLIC/themes/exoplasim/css/" +"$PHP_CLI" -r 'file_put_contents($argv[1]."/index.php", " budget: raise ValueError('Result output exceeds configured storage budget.') + print(json.dumps({'cycle':cycle+1,'cycles':run['cycles'],'dataset':dataset_id,'output':str(output)}),flush=True) + +if __name__=='__main__': main() diff --git a/scripts/web/parameters.py b/scripts/web/parameters.py new file mode 100644 index 0000000..fc80e05 --- /dev/null +++ b/scripts/web/parameters.py @@ -0,0 +1,238 @@ +import argparse +import ast +import copy +import hashlib +import inspect +import json +import math +import os +from pathlib import Path +import re + + +MANAGED = {'starspec', 'restartfile', 'runscript', 'resources', 'landmap', 'topomap', 'otherargs', 'runsteps', 'noutput'} + + +def _type(value): + if isinstance(value, bool): + return 'boolean' + if isinstance(value, int): + return 'integer' + if isinstance(value, float): + return 'number' + if isinstance(value, dict): + return 'object' + if isinstance(value, list): + return 'array' + return 'string' + + +def catalog(config): + import exoplasim + model = exoplasim.Model + signature = inspect.signature(model.configure) + documentation = inspect.getdoc(model.configure) + entries, group, active = {}, 'Parameters', None + for line in documentation.splitlines(): + if line.strip() in ('Notes', 'References'): + active = None + heading = re.fullmatch(r'\s*\*\*(.+?)\*\*\s*', line) + if heading: + group, active = heading[1], None + match = re.match(r'^\s*(\w+)\s*[:;]\s*(.+)', line) + if match and match[1] in signature.parameters: + active = match[1] + entries[active] = {'declaration': match[2], 'description': '', 'group': group} + elif active and line.strip(): + entries[active]['description'] += ' ' + line.strip() + source = inspect.getsource(model.configure) + tree = ast.parse(Path(inspect.getsourcefile(model)).read_text()) + fields = [] + for name, parameter in signature.parameters.items(): + if name == 'self': + continue + default = copy.deepcopy(parameter.default) + doc = entries.get(name, {}) + declaration = doc.get('declaration', '') + kind = _type(default) if default is not None else ('integer' if re.search(r'\bint(?:eger)?\b', declaration) else 'string' if ('str' in declaration or '{' in declaration) else 'number') + field = {'name': name, 'label': name, 'type': kind, 'default': default, 'nullable': default is None, + 'description': doc.get('description', '').strip(), 'group': doc.get('group', 'Parameters'), 'editable': name not in MANAGED} + if declaration.startswith('{'): + choices = [s.strip().strip('\"\' }') for s in declaration.split('}', 1)[0].lstrip('{').split(',')] + choices = [s for s in choices if s and s != 'None'] + if all(re.fullmatch(r'-?\d+', s) for s in choices): + field['type'], field['choices'] = 'integer', [int(s) for s in choices] + else: + field['type'], field['choices'] = 'string', choices + if isinstance(default, dict): + field['properties'] = {key: {'type': _type(value), 'default': value} for key, value in default.items()} + if name == 'gravity': + field['implicit_default'] = next(node.value.value for node in ast.walk(tree) if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == name for target in node.targets) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, (int, float))) + if name == 'ozone': + keys = sorted({node.slice.value for node in ast.walk(tree) if isinstance(node, ast.Subscript) and isinstance(node.value, ast.Name) and node.value.id == name and isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str)}) + field.update(type='object', types=['boolean', 'object'], properties={key: {'type': 'number'} for key in keys}, required=keys) + if name == 'vegetation': + field.update(type='integer', types=['boolean', 'integer'], choices=[0, 1, 2]) + if name == 'physicsfilter': + field['pattern'] = r'^(?:(?:gp|sp|none|cesaro|exp|lh)(?:\|(?:gp|sp|none|cesaro|exp|lh))*)?$' + if name == 'aerofile': + field['choices'] = sorted(p.name for p in (Path(exoplasim.__file__).parent / 'hazeconstants').glob('*.dat')) + if field['type'] == 'string' and not field.get('choices') and not field.get('pattern'): + field['editable'] = False + lead = field['description'].split('.', 1)[0].split(',', 1)[0] + lead = re.split(r'\s+in\s+', lead, maxsplit=1)[0] + if 3 < len(lead) < 55 and not re.match(r'^(True|False|If|Can|A |The |Whether)', lead): + field['label'] = lead + fields.append(field) + limits = config['limits'] + constructor = inspect.signature(model) + model_defaults = {key: constructor.parameters[key].default for key in ('resolution', 'layers', 'ncpus')} + model_defaults['ncpus'] = min(model_defaults['ncpus'], limits['max_cpus']) + model_rules = {'resolution': {'type': 'string', 'choices': limits['resolutions']}, + 'layers': {'type': 'integer', 'min': 2, 'max': limits['max_layers']}, + 'ncpus': {'type': 'integer', 'min': 1, 'max': limits['max_cpus']}} + run_rules = {'cycles': {'type': 'integer', 'min': 1, 'max': limits['max_cycles']}, + 'steps': {'type': 'integer', 'nullable': True, 'min': 1, 'max': limits['max_steps']}} + if __package__: + from .terrain import surface_schema + from .mapcatalog import map_schema + else: + from terrain import surface_schema + from mapcatalog import map_schema + maps = map_schema() + maps['grids'] = {key: maps['grids'][key] for key in limits['resolutions'] if key in maps['grids']} + return {'fields': fields, 'model': model_rules, 'run': run_rules, 'model_defaults': model_defaults, + 'run_defaults': config['run_defaults'], 'surface': surface_schema(), 'maps': maps, + 'map_fields': maps['fields'], 'map_grids': maps['grids'], 'limits': limits, + 'source_hash': hashlib.sha256(source.encode()).hexdigest()} + + +def validate_value(value, rule): + if value is None: + return (None, None) if rule.get('nullable') else (value, 'A value is required.') + types = rule.get('types', [rule['type']]) + actual = _type(value) + if actual == 'integer' and 'number' in types: + actual = 'number' + if actual not in types: + return value, 'Expected ' + ' or '.join(types) + '.' + if isinstance(value, (float, int)) and not isinstance(value, bool): + try: + finite = math.isfinite(value) + except OverflowError: + finite = False + if not finite: + return value, 'Use a finite number.' + if 'min' in rule and value < rule['min'] or 'max' in rule and value > rule['max']: + return value, 'The value is outside the allowed range.' + if 'choices' in rule and value not in rule['choices']: + return value, 'Select an allowed value.' + if isinstance(value, str) and (len(value) > 200 or 'pattern' in rule and not re.fullmatch(rule['pattern'], value)): + return value, 'The value has an invalid format.' + if isinstance(value, dict): + properties = rule.get('properties', {}) + unknown = set(value) - set(properties) + if unknown: + return value, 'Unknown keys: ' + ', '.join(sorted(unknown)) + '.' + result = copy.deepcopy(rule.get('default')) if isinstance(rule.get('default'), dict) else {} + for key, item in value.items(): + normalized, error = validate_value(item, properties[key]) + if error: + return value, key + ': ' + error + result[key] = normalized + missing = set(rule.get('required', [])) - set(result) + if missing: + return value, 'Required keys: ' + ', '.join(sorted(missing)) + '.' + value = result + return value, None + + +def validate_spec(spec, config): + schema = json.loads(Path(config['_paths']['catalog']).read_text()) + errors, normalized = {}, {} + if not isinstance(spec, dict): + return {'ok': False, 'spec': spec, 'errors': {'spec': 'Expected an object.'}} + for key in set(spec) - {'model', 'run', 'parameters', 'surface', 'maps'}: + errors[key] = 'Unknown spec section.' + for section in ('model', 'run'): + values = spec.get(section, {}) + if not isinstance(values, dict): + errors[section] = 'Expected an object.' + continue + normalized[section] = copy.deepcopy(schema[section + '_defaults']) + for key, value in values.items(): + if key not in schema[section]: + errors[section + '.' + key] = 'Unknown field.' + continue + result, error = validate_value(value, schema[section][key]) + if error: + errors[section + '.' + key] = error + else: + normalized[section][key] = result + parameters = spec.get('parameters', {}) + if parameters == []: + parameters = {} + normalized['parameters'] = {} + fields = {field['name']: field for field in schema['fields']} + if not isinstance(parameters, dict): + errors['parameters'] = 'Expected an object.' + else: + for name, value in parameters.items(): + field = fields.get(name) + if not field: + errors['parameters.' + name] = 'Unknown parameter.' + continue + if not field['editable']: + if value != field['default']: + errors['parameters.' + name] = 'This input is managed by the application.' + continue + result, error = validate_value(value, field) + if error: + errors['parameters.' + name] = error + else: + normalized['parameters'][name] = result + if 'maps' in spec: + maps = spec['maps'] + rules = schema.get('maps', {}).get('fields', {}) + if not isinstance(maps, dict): + errors['maps'] = 'Expected a map version object.' + else: + normalized['maps'] = {} + for field, version in maps.items(): + if field not in rules: + errors['maps.' + field] = 'Unsupported map field.' + elif not isinstance(version, str) or not re.fullmatch(r'[a-f0-9]{32}', version): + errors['maps.' + field] = 'Expected a saved map version ID.' + else: + normalized['maps'][field] = version + if 'surface' in spec: + surface = spec['surface'] + rules = schema['surface'] + if not isinstance(surface, dict): + errors['surface'] = 'Expected an object.' + else: + normalized['surface'] = {} + for key in set(surface) - set(rules): + errors['surface.' + key] = 'Unknown field.' + for key, rule in rules.items(): + result, error = validate_value(surface.get(key, rule.get('default')), rule) + if error: + errors['surface.' + key] = error + else: + normalized['surface'][key] = result + return {'ok': not errors, 'spec': normalized, 'errors': errors} + + +if __name__ == '__main__': + from config import load_config + parser = argparse.ArgumentParser() + parser.add_argument('--config') + parser.add_argument('--output') + args = parser.parse_args() + configuration = load_config(args.config) + output = Path(args.output or configuration['_paths']['catalog']) + output.parent.mkdir(parents=True, exist_ok=True) + temporary = output.with_name(output.name + '.' + str(os.getpid()) + '.tmp') + temporary.write_text(json.dumps(catalog(configuration), indent=2) + '\n') + temporary.chmod(0o644) + temporary.replace(output) diff --git a/scripts/web/planet.py b/scripts/web/planet.py new file mode 100644 index 0000000..8b3bf48 --- /dev/null +++ b/scripts/web/planet.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +from pathlib import Path +import sys + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--config', required=True) + parser.add_argument('--job', required=True) + args = parser.parse_args() + from config import load_config + config = load_config(args.config) + job = json.loads(Path(args.job).read_text()) + artifact_root = Path(job['directory']) / 'artifacts' + runtime = Path(config['_paths']['runtime']) / 'python' + sys.path.insert(0, str(runtime)) + from terrain import generate_planet + from parameters import validate_spec + generated = generate_planet(job['spec'], config, artifact_root / 'planet-work') + validated = validate_spec(generated['spec'], config) + if not validated['ok']: + raise ValueError('Generated planet spec failed validation: ' + json.dumps(validated['errors'], sort_keys=True)) + dataset = generated['dataset'] + dataset_id = dataset['id'] + source = artifact_root / 'planet-work' / 'preview' + destination = artifact_root / 'datasets' / dataset_id + destination.mkdir(parents=True, exist_ok=True) + for item in source.iterdir(): + if item.is_file(): + os.replace(item, destination / item.name) + index = json.loads((destination / 'index.json').read_text()) + if index['id'] != dataset_id: + raise ValueError('Dataset ID does not match its output directory.') + manifest = json.loads((artifact_root / 'planet-work' / 'planet.json').read_text()) + manifest['recipe'] = validated['spec'] + manifest['surface_files'] = {key: 'planet-work/' + name for key, name in manifest['surface_files'].items()} + (artifact_root / 'planet.json').write_text(json.dumps(manifest, sort_keys=True)) + used = sum(path.stat().st_size for path in artifact_root.rglob('*') if path.is_file()) + if used > config['max_result_bytes']: + raise ValueError('Generated output exceeds the configured storage limit.') + print(json.dumps({'planet': True, 'spec': validated['spec'], 'dataset': dataset_id, + 'cycle': 1, 'cycles': 1}), flush=True) + + +if __name__ == '__main__': + main() diff --git a/scripts/web/results.py b/scripts/web/results.py new file mode 100644 index 0000000..87570cf --- /dev/null +++ b/scripts/web/results.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import argparse +from contextlib import contextmanager +import json +import os +from pathlib import Path +import re +import shutil +import tempfile + +import numpy as np + + +NAME = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$") +COORDS = {"lat", "lon", "lev", "levp", "time"} + + +def _atomic_json(path: Path, value: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + os.fchmod(fd, 0o640) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + json.dump(value, stream, separators=(",", ":"), allow_nan=False) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def _metadata(path: Path) -> dict: + result = {} + sidecar = path.with_name(path.stem + "_metadata.npz") + if not sidecar.is_file(): + return result + with np.load(sidecar, allow_pickle=False) as archive: + for name in archive.files: + row = archive[name] + if row.ndim != 1 or row.size < 3: + continue + result[name] = (str(row[1]), str(row[2])) + return result + + +def latitude_weights(lat): + nodes, weights = np.polynomial.legendre.leggauss(len(lat)) + order = np.argsort(lat) + sine = np.sin(np.deg2rad(lat[order])) + if np.allclose(sine, nodes, atol=1e-6, rtol=0): + result = np.empty_like(weights) + result[order] = weights + return result, 'Gaussian quadrature' + edges = np.concatenate(([-1.0], (sine[:-1] + sine[1:]) / 2, [1.0])) + result = np.empty_like(sine) + result[order] = np.diff(edges) + return result, 'Latitude cell area' + + +def _header(archive, name): + with archive.zip.open(name + '.npy') as stream: + version = np.lib.format.read_magic(stream) + reader = np.lib.format.read_array_header_1_0 if version == (1, 0) else np.lib.format.read_array_header_2_0 + return reader(stream) + + +@contextmanager +def _field(archive, name, destination, memory_limit): + if archive.zip.getinfo(name + '.npy').file_size <= memory_limit: + yield archive[name] + return + fd, temporary = tempfile.mkstemp(prefix='.field-', suffix='.npy', dir=destination) + array = None + try: + with os.fdopen(fd, 'wb') as output, archive.zip.open(name + '.npy') as source: + shutil.copyfileobj(source, output, length=1024 * 1024) + array = np.load(temporary, mmap_mode='r', allow_pickle=False) + yield array + finally: + if array is not None: + del array + os.unlink(temporary) + + +def prepare_results(npz_path, destination, dataset_id, config=None): + if config is None: + if __package__: + from .config import load_config + else: + from config import load_config + config = load_config() + source, dest = Path(npz_path), Path(destination) + if not re.fullmatch(r"[a-f0-9]{32}", str(dataset_id)): + raise ValueError("dataset_id must contain 32 lowercase hexadecimal characters") + dest.mkdir(parents=True, exist_ok=True) + with np.load(source, allow_pickle=False) as archive: + required = ("lat", "lon", "time") + if any(key not in archive for key in required): + raise ValueError("NPZ must contain lat, lon, and time coordinates") + lat = np.asarray(archive["lat"], dtype=np.float64) + lon = np.asarray(archive["lon"], dtype=np.float64) + time = np.asarray(archive["time"], dtype=np.float64) + levels = np.asarray(archive["lev"], dtype=np.float64).tolist() if "lev" in archive else [0.0] + if any(a.ndim != 1 or not a.size or not np.isfinite(a).all() for a in (lat, lon, time)): + raise ValueError("coordinates must be non-empty finite vectors") + metadata = _metadata(source) + time_unit = metadata.get("time", ("", ""))[1] or "" + fields, total_output = [], 0 + weights, weighting = latitude_weights(lat) + weights = weights[:, None] + for name in archive.files: + if name in COORDS or not NAME.fullmatch(name): + continue + shape, _, dtype = _header(archive, name) + if dtype.kind not in 'fiu': + continue + if len(shape) not in (3, 4) or shape[0] != len(time) or shape[-2:] != (len(lat), len(lon)): + continue + if len(shape) == 3: + field_shape = (shape[0], 1, shape[1], shape[2]) + else: + field_shape = shape + if shape[1] != len(levels): + continue + byte_count = int(np.prod(field_shape, dtype=np.int64)) * 4 + total_output += byte_count + if total_output > config['max_result_bytes']: + raise ValueError("Result output exceeds the configured storage limit.") + required_space = byte_count + archive.zip.getinfo(name + '.npy').file_size + if shutil.disk_usage(dest).free - required_space < config['minimum_free_bytes']: + raise ValueError("Not enough free space to prepare model output.") + label, unit = metadata.get(name, (name, "")) + minimum, maximum, means = [], [], [] + fd, temporary = tempfile.mkstemp(prefix=f".{name}.", suffix=".tmp", dir=dest) + os.fchmod(fd, 0o640) + try: + with os.fdopen(fd, "wb") as stream, _field(archive, name, dest, config['max_field_memory_bytes']) as array: + for ti in range(shape[0]): + frame = array[ti] if len(shape) == 4 else array[ti][None, ...] + for li in range(field_shape[1]): + plane = np.array(frame[li], dtype=np.float32, copy=True) + np.nan_to_num(plane, copy=False, nan=np.nan, posinf=np.nan, neginf=np.nan) + finite = np.isfinite(plane) + minimum.append(float(np.min(plane[finite])) if finite.any() else None) + maximum.append(float(np.max(plane[finite])) if finite.any() else None) + weighted = np.where(finite, plane * weights, 0.0) + denominator = np.sum(np.where(finite, weights, 0.0), dtype=np.float64) + means.append(float(np.sum(weighted, dtype=np.float64) / denominator) if denominator else None) + stream.write(np.asarray(plane, dtype=" item["max"] or (key == "seed" and int(value) != value): + raise ValueError(f"surface.{key} outside supported range") + if key == "seed": + result[key] = int(value) + return result + + +def write_map_sra(path, resolution, field, destination, gravity, landmask=None): + try: + from .mapcatalog import FIELDS + except ImportError: + from mapcatalog import FIELDS + contract = FIELDS[field] + code = contract['native_code'] + raw = np.fromfile(path, dtype=' contract['max']): + raise ValueError(f'{field} values outside supported range') + if contract['categorical'] and not np.isin(grid, (0.0, 1.0)).all(): + raise ValueError('land mask values must be 0 or 1') + if field == 'height': + if landmask is None: + land = np.ones(grid.shape, dtype=bool) + else: + mask_path = Path(landmask) + if mask_path.suffix == '.f32': + values = np.fromfile(mask_path, dtype=' 0.5 + else: + values = np.fromstring(mask_path.read_text(), sep=' ')[8:] + if values.size != grid.size: + raise ValueError('land mask grid has invalid dimensions') + land = values.reshape(grid.shape) > 0.5 + grid = np.where(land & (grid > 0.0), grid * gravity, 0.0) + destination = Path(destination) + months = contract.get('months', 1) + with destination.open('w', encoding='ascii') as stream: + for _ in range(months): + stream.write(''.join(f'{value:10d}' for value in (code, 0, 0, 0, nlat * 2, nlat, 0, 0)) + '\n') + stream.write(''.join(f'{value:16.6E}' for value in grid.ravel()) + '\n') + return str(destination) + + +def _gaussian_grid(resolution): + nlat = _RESOLUTIONS[resolution] + x, weights = np.polynomial.legendre.leggauss(nlat) + lat = np.rad2deg(np.arcsin(x[::-1])) + lon = np.arange(nlat * 2, dtype=np.float64) * (360.0 / (nlat * 2)) + return lat, lon, weights[::-1] + + +def _terrain(surface, lat, lon, lat_weights): + rng = np.random.default_rng(surface['seed']) + lonr, latr = np.meshgrid(np.deg2rad(lon), np.deg2rad(lat)) + xyz = np.stack((np.cos(latr) * np.cos(lonr), np.cos(latr) * np.sin(lonr), np.sin(latr)), axis=-1) + field = np.zeros(latr.shape) + for degree in range(1, 9): + count = max(4, 2 * degree + 1) + centers = rng.normal(size=(count, 3)) + centers /= np.linalg.norm(centers, axis=1)[:, None] + amplitudes = rng.normal(size=count) + field += (np.einsum('...c,kc->...k', xyz, centers) ** degree @ amplitudes) / degree ** 1.25 + field -= np.average(field, weights=np.broadcast_to(lat_weights[:, None], field.shape)) + spread = np.std(field) + if spread: + field /= spread + fine = np.sin(surface['scale'] * 2.0 * xyz[..., 0] + surface['scale'] * 3.0 * xyz[..., 1] + surface['scale'] * xyz[..., 2]) + return .75 * field + .25 * fine + + +def _reference_gravity(config): + path = Path(config['_paths']['catalog']) + fields = json.loads(path.read_text())['fields'] + return next(field['implicit_default'] for field in fields if field['name'] == 'gravity') + + +def prepare_surface(surface, model, config, destination): + surface = _validate_surface(surface) + resolution = model.get("resolution") + if resolution not in _RESOLUTIONS: + raise ValueError("unsupported model resolution") + if not isinstance(config, dict): + raise ValueError("config must be a mapping") + lat, lon, lat_weights = _gaussian_grid(resolution) + field = _terrain(surface, lat, lon, lat_weights) + cell_weights = np.broadcast_to(lat_weights[:, None], field.shape) + fraction = surface["land_fraction"] + if fraction == 0: + land = np.zeros(field.shape, dtype=bool) + threshold = float(np.max(field)) + elif fraction == 1: + land = np.ones(field.shape, dtype=bool) + threshold = float(np.min(field)) + else: + order = np.argsort(field.ravel())[::-1] + cumulative = np.cumsum(cell_weights.ravel()[order]) / np.sum(cell_weights) + position = int(np.searchsorted(cumulative, fraction, side="left")) + threshold = float(field.ravel()[order[position]]) + land = field >= threshold + # Boundary ties are resolved by deterministic cell order to attain closest discrete area. + equal = np.flatnonzero(field.ravel() == threshold) + desired = fraction * np.sum(cell_weights) + above = np.sum(cell_weights[field > threshold]) + choose = max(0, min(len(equal), int(round((desired - above) / cell_weights.flat[0])))) + land.ravel()[equal] = False + land.ravel()[equal[:choose]] = True + bathymetry = np.where(land, np.maximum(field - threshold, 0.0), np.minimum(field - threshold, 0.0)) + maximum = float(np.max(np.abs(bathymetry))) + elevation = bathymetry * (surface["relief"] / maximum) if maximum else bathymetry + bathymetry = np.minimum(elevation, 0.0) + gravity = config.get('parameters', {}).get('gravity', config.get('gravity')) + gravity = float(_reference_gravity(config) if gravity is None else gravity) + geopotential = np.where(land, np.maximum(elevation, 0.0) * gravity, 0.0) + root = Path(destination) + root.mkdir(parents=True, exist_ok=True) + token = hashlib.sha256(json.dumps({"version": GENERATOR_VERSION, "surface": surface, "resolution": resolution}, sort_keys=True).encode()).hexdigest()[:32] + npz = root / f"surface-{token}.npz" + metadata = root / f"surface-{token}_metadata.npz" + np.savez_compressed(npz, lat=lat, lon=lon, time=np.array([0.0]), elevation=elevation[None], bathymetry=bathymetry[None], landmask=land.astype(np.float32)[None]) + np.savez(metadata, elevation=np.array(["Elevation", "Procedural elevation", "m"]), bathymetry=np.array(["Bathymetry", "Signed procedural bathymetry", "m"]), landmask=np.array(["Land mask", "Procedural land fraction", "1"])) + nlat = len(lat) + prefix = f"N{nlat:03d}_surf_" + topopath, landpath = root / f"{prefix}0129.sra", root / f"{prefix}0172.sra" + def write_sra(path, code, values): + with path.open("w", encoding="ascii") as stream: + stream.write("".join(f"{value:10d}" for value in (code, 0, 0, 0, len(lon), nlat, 0, 0)) + "\n") + stream.write("".join(f"{value:16.6E}" for value in np.asarray(values).ravel()) + "\n") + write_sra(topopath, 129, geopotential) + write_sra(landpath, 172, land.astype(float)) + return {"landmap": str(landpath), "topomap": str(topopath), "preview": str(npz), "metadata": str(metadata), "land_fraction_actual": float(np.sum(cell_weights * land) / np.sum(cell_weights)), "latitude": lat.tolist(), "longitude": lon.tolist()} + + +def generate_planet(recipe, config, destination): + if not isinstance(recipe, dict) or set(recipe) != {"model", "parameters", "run", "surface"}: + raise ValueError("recipe must contain model, parameters, run, and surface") + spec = json.loads(json.dumps(recipe)) + surface = _validate_surface(spec["surface"]) + spec["surface"] = surface + model = spec["model"] + if model.get("resolution") not in _RESOLUTIONS: + raise ValueError("unsupported model resolution") + rng = np.random.default_rng(surface["seed"]) + params = spec["parameters"] + # Mass is retained as generator evidence; ExoPlaSim accepts radius and gravity instead. + mass = float(rng.uniform(0.6, 1.4) ** 2.7) + radius = mass ** 0.27 + gravity = _reference_gravity(config) * mass / radius ** 2 + params.setdefault("radius", radius) + params.setdefault("gravity", gravity) + params.setdefault("startemp", float(rng.uniform(4000, 6200))) + params.setdefault("flux", float(rng.uniform(700, 1400))) + params.setdefault("rotationperiod", float(rng.uniform(0.5, 2.0))) + params.setdefault("obliquity", float(rng.uniform(0, 35))) + params.setdefault("eccentricity", float(rng.uniform(0, 0.15))) + params.setdefault("pN2", 1.0) + params.setdefault("pCO2", float(rng.uniform(1e-4, 2e-2))) + surface_config = dict(config) + surface_config['parameters'] = params + dataset = prepare_surface(surface, model, surface_config, destination) + dataset_id = hashlib.sha256(json.dumps({"version": GENERATOR_VERSION, "recipe": spec}, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:32] + if __package__: + from .results import prepare_results + else: + from results import prepare_results + index = prepare_results(dataset["preview"], Path(destination) / "preview", dataset_id, config=config) + manifest = {"generator_version": GENERATOR_VERSION, "seed": surface["seed"], "sampled_mass_earth_units": mass, "recipe": spec, "dataset_id": dataset_id, "land_fraction_actual": dataset["land_fraction_actual"], "surface_files": {"landmap": Path(dataset["landmap"]).name, "topomap": Path(dataset["topomap"]).name, "preview": Path(dataset["preview"]).name}} + (Path(destination) / "planet.json").write_text(json.dumps(manifest, sort_keys=True, indent=2), encoding="utf-8") + dataset["index"] = index + dataset["id"] = dataset_id + return {"spec": spec, "dataset": dataset} diff --git a/tests/test_web_model.py b/tests/test_web_model.py new file mode 100644 index 0000000..df40ec0 --- /dev/null +++ b/tests/test_web_model.py @@ -0,0 +1,105 @@ +import json +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import patch + +from scripts.web import model + + +class ModelCliTests(unittest.TestCase): + def test_map_snapshot_is_converted_to_native_sra(self): + import numpy as np + from scripts.web.terrain import write_map_sra + with tempfile.TemporaryDirectory() as directory: + root=Path(directory); raw=root/'height.f32'; np.arange(2048,dtype='0.0)) + roughness=root/'roughness.f32'; np.full(2048,2.0,dtype='['pipe','w'],2=>['pipe','w']],$pipes,dirname(__DIR__)); + if(!is_resource($process))throw new RuntimeException('Could not start PHP route fixture.'); + fclose($pipes[0]);$body=stream_get_contents($pipes[1]);$error=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);$status=proc_close($process); + putenv('EXOPLASIM_API_TEST_CHILD');putenv('EXOPLASIM_API_TEST_QUERY'); + if($status!==0)throw new RuntimeException('PHP route fixture failed: '.$error); + return $body; +} +function reply_data(string $body): array { + $reply=json_decode($body,true); + check(is_array($reply),'Expected a JSON response.'); + return $reply; +} +function create_job(PDO $db,string $id,string $kind,string $spec,string $now): void { + $s=$db->prepare("INSERT INTO jobs(id,owner,name,spec,kind,status,total_cycles,created_at,updated_at) VALUES(?,?,?,?,?,'completed',1,?,?)"); + $s->execute([$id,'local',$kind.' test',$spec,$kind,$now,$now]); +} +function write_json(string $path,array $value): void { file_put_contents($path,json_encode($value,JSON_THROW_ON_ERROR)); } + +$root=dirname(__DIR__);$tmp=sys_get_temp_dir().'/exoplasim-api-'.bin2hex(random_bytes(5));mkdir($tmp,0700,true); +foreach(['catalog','jobs','runtime','data'] as $dir)mkdir($tmp.'/'.$dir,0750,true); +$config=json_decode(file_get_contents($root.'/config/web.json'),true,512,JSON_THROW_ON_ERROR); +$config['storage']['root']=$tmp;$config['storage']['catalog']='catalog/parameters.json';$config['max_result_bytes']=256;$config['max_field_memory_bytes']=64; +file_put_contents($tmp.'/config.json',json_encode($config,JSON_THROW_ON_ERROR));putenv('EXOPLASIM_CONFIG='.$tmp.'/config.json'); +try { + $command=escapeshellarg($root.'/.venv/bin/python').' '.escapeshellarg($root.'/scripts/web/parameters.py').' --config '.escapeshellarg($tmp.'/config.json').' --output '.escapeshellarg($tmp.'/catalog/parameters.json').' 2>&1'; + exec($command,$output,$status);check($status===0,'Could not generate the test catalog: '.implode("\n",$output)); + $db=LabDb::connection();$now=gmdate('c');$modelId=str_repeat('a',32);$modelDataset=str_repeat('c',32);$planetId=str_repeat('b',32);$planetDataset=str_repeat('d',32);$token=str_repeat('e',32); + $spec=json_encode(['model'=>[],'run'=>['cycles'=>1],'parameters'=>(object)[],'maps'=>['height'=>$token]],JSON_THROW_ON_ERROR); + create_job($db,$modelId,'model',$spec,$now);create_job($db,$planetId,'planet',$spec,$now); + $modelRoot=$tmp.'/jobs/'.$modelId;$modelArtifacts=$modelRoot.'/artifacts';$modelData=$modelArtifacts.'/datasets/'.$modelDataset; + mkdir($modelData,0750,true);mkdir($modelRoot.'/runtime',0750,true);mkdir($modelRoot.'/inputs/maps',0750,true);mkdir($modelRoot.'/inputs/native',0750,true); + file_put_contents($modelRoot.'/inputs/maps/height.f32','fixture input map');file_put_contents($modelRoot.'/inputs/native/height.sra','fixture native input'); + file_put_contents($modelRoot.'/worker.log','fixture worker log');file_put_contents($modelRoot.'/runtime/GUI.cfg','fixture model config');file_put_contents($modelRoot.'/runtime/MOST.00000.npz','fixture raw model NPZ');file_put_contents($modelRoot.'/runtime/MOST.00000_metadata.npz','fixture raw model metadata');file_put_contents($modelRoot.'/runtime/plasim_restart','fixture restart');file_put_contents($modelRoot.'/runtime/MOST_REST.00000','fixture cycle restart'); + $index=['id'=>$modelDataset,'lat'=>[-30,30],'lon'=>[0,120,240],'time'=>[0,1],'time_unit'=>'years','levels'=>[0],'fields'=>[['name'=>'temperature','label'=>'Temperature','unit'=>'K','shape'=>[2,1,2,3]]]]; + write_json($modelData.'/index.json',$index);write_json($modelData.'/temperature.json',['mean'=>[[280],[281]],'min'=>[[270],[271]],'max'=>[[290],[291]]]);$fieldBytes=pack('g*',1,2,3,4,5,6,7,8,9,10,11,12);file_put_contents($modelData.'/temperature.f32',$fieldBytes); + write_json($modelArtifacts.'/index.json',['datasets'=>[['id'=>$modelDataset,'label'=>'Cycle 1']],'latest'=>$modelDataset]); + $planetRoot=$tmp.'/jobs/'.$planetId;$planetArtifacts=$planetRoot.'/artifacts';$planetData=$planetArtifacts.'/datasets/'.$planetDataset; + mkdir($planetData,0750,true);mkdir($planetArtifacts.'/planet-work',0750,true);mkdir($planetRoot.'/runtime',0750,true); + file_put_contents($planetRoot.'/worker.log','planet worker log');file_put_contents($planetArtifacts.'/planet-work/surface-'.$token.'.npz','fixture terrain NPZ');file_put_contents($planetArtifacts.'/planet-work/surface-'.$token.'_metadata.npz','fixture terrain metadata');file_put_contents($planetArtifacts.'/planet.json','fixture terrain recipe');file_put_contents($planetArtifacts.'/planet-work/N064_surf_0129.sra','fixture topography SRA');file_put_contents($planetArtifacts.'/planet-work/N064_surf_0172.sra',str_repeat('s',257)); + $planetIndex=['id'=>$planetDataset,'lat'=>[-30,30],'lon'=>[0,120,240],'time'=>[0],'time_unit'=>'','levels'=>[0],'fields'=>[['name'=>'elevation','label'=>'Elevation','unit'=>'m','shape'=>[1,1,2,3]]]]; + write_json($planetData.'/index.json',$planetIndex);write_json($planetArtifacts.'/index.json',['datasets'=>[['id'=>$planetDataset,'label'=>'Generated planet surface']],'latest'=>$planetDataset]); + + $schema=reply_data(request_api(['api'=>'schema']));check($schema['ok']===true&&isset($schema['data']['fields']),'The ordinary api=schema query must resolve.'); + $unknown=reply_data(request_api(['api'=>'not_found']));check(empty($unknown['ok'])&&isset($unknown['errors']['route']),'Unknown query operations must return a structured error.'); + $modelResults=reply_data(request_api(['api'=>'results','id'=>$modelId]));check($modelResults['ok']===true,'The query-param results route must resolve.');$modelLinks=array_column($modelResults['data']['downloads'],'id'); + foreach(['log','model-config','raw:'.$modelDataset,'raw-metadata:'.$modelDataset,'dataset-index:'.$modelDataset,'restart:plasim_restart','restart:MOST_REST.00000','field:'.$modelDataset.':temperature'] as $link)check(in_array($link,$modelLinks,true),'Results must list '.$link.'.'); + foreach(['input-map:height','native-input:height'] as $link)check(in_array($link,$modelLinks,true),'Results must list '.$link.'.'); + check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'input-map:height'])==='fixture input map','Captured map download must work.'); + check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'native-input:height'])==='fixture native input','Retained native input download must work.'); + check(empty(reply_data(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'native-input:unknown']))['ok']),'Unknown native inputs must fail.'); + check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'log'])==='fixture worker log','Log download must work without dataset or field parameters.'); + check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'model-config'])==='fixture model config','Model config download must work.'); + check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'raw:'.$modelDataset])==='fixture raw model NPZ','Raw model output download must work.'); + check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'raw-metadata:'.$modelDataset])==='fixture raw model metadata','Raw model metadata download must work.'); + check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'dataset-index:'.$modelDataset])===file_get_contents($modelData.'/index.json'),'Dataset index download must work.'); + check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'restart:plasim_restart'])==='fixture restart','Model restart download must work.'); + check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'restart:MOST_REST.00000'])==='fixture cycle restart','Cycle restart download must work.'); + check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'field:'.$modelDataset.':temperature'])===$fieldBytes,'Field download must return the allowlisted binary field.'); + $missing=reply_data(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'field:'.$modelDataset.':missing']));check(empty($missing['ok']),'Missing field download must fail.'); + $arbitrary=reply_data(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'../../etc/passwd']));check(empty($arbitrary['ok']),'Arbitrary paths must not be downloadable.'); + $frame=reply_data(request_api(['api'=>'frame','id'=>$modelId,'dataset'=>$modelDataset,'field'=>'temperature','time'=>2,'level'=>0]));check(empty($frame['ok'])&&isset($frame['errors']['selection']),'Out-of-range frames must fail.'); + check(request_api(['api'=>'frame','id'=>$modelId,'dataset'=>$modelDataset,'field'=>'temperature','time'=>0,'level'=>0])===substr($fieldBytes,0,24),'Valid frames must return one binary frame.'); + $secondDataset=str_repeat('f',32);$secondData=$modelArtifacts.'/datasets/'.$secondDataset;mkdir($secondData,0750,true);$secondIndex=$index;$secondIndex['id']=$secondDataset; + write_json($secondData.'/index.json',$secondIndex);file_put_contents($secondData.'/temperature.f32',$fieldBytes);file_put_contents($modelRoot.'/runtime/MOST.00001.npz','second cycle NPZ'); + write_json($modelArtifacts.'/index.json',['datasets'=>[['id'=>$modelDataset,'label'=>'Cycle 1'],['id'=>$secondDataset,'label'=>'Cycle 2']],'latest'=>$secondDataset]); + $secondResults=reply_data(request_api(['api'=>'results','id'=>$modelId]));$secondLinks=array_column($secondResults['data']['downloads'],'id'); + check(in_array('raw:'.$secondDataset,$secondLinks,true),'Latest-cycle results must list their own raw output.'); + check(!in_array('raw:'.$modelDataset,$secondLinks,true)&&!in_array('field:'.$modelDataset.':temperature',$secondLinks,true),'Download listings must not scan and include unrelated cycles.'); + check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'raw:'.$modelDataset])==='fixture raw model NPZ','Older-cycle downloads must still resolve directly.'); + $planetResults=reply_data(request_api(['api'=>'results','id'=>$planetId]));check($planetResults['ok']===true,'Planet results route must resolve.');$planetLinks=array_column($planetResults['data']['downloads'],'id'); + foreach(['terrain-recipe','dataset-index:'.$planetDataset,'sra:N064_surf_0129.sra','raw:'.$planetDataset.':surface-'.$token.'.npz','raw-metadata:'.$planetDataset.':surface-'.$token] as $link)check(in_array($link,$planetLinks,true),'Planet results must list '.$link.'.'); + check(request_api(['api'=>'download','id'=>$planetId,'artifact'=>'sra:N064_surf_0129.sra'])==='fixture topography SRA','Native SRA download must work.'); + check(request_api(['api'=>'download','id'=>$planetId,'artifact'=>'raw:'.$planetDataset.':surface-'.$token.'.npz'])==='fixture terrain NPZ','Raw terrain preview download must work.'); + check(request_api(['api'=>'download','id'=>$planetId,'artifact'=>'raw-metadata:'.$planetDataset.':surface-'.$token])==='fixture terrain metadata','Raw terrain metadata download must work.'); + check(request_api(['api'=>'download','id'=>$planetId,'artifact'=>'dataset-index:'.$planetDataset])===file_get_contents($planetData.'/index.json'),'Planet dataset index download must work.'); + check(request_api(['api'=>'download','id'=>$planetId,'artifact'=>'terrain-recipe'])==='fixture terrain recipe','Terrain recipe download must work.'); + $oversized=reply_data(request_api(['api'=>'download','id'=>$planetId,'artifact'=>'sra:N064_surf_0172.sra']));check(empty($oversized['ok'])&&str_contains($oversized['errors']['artifact']??'','size limit'),'Oversized artifacts must be rejected.'); + echo "PHP query-route artifact tests passed\n"; +} finally { + putenv('EXOPLASIM_CONFIG');putenv('EXOPLASIM_API_TEST_CHILD');putenv('EXOPLASIM_API_TEST_QUERY'); + $it=new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmp,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST); + foreach($it as $file){$file->isDir()?rmdir($file->getPathname()):unlink($file->getPathname());} + rmdir($tmp); +} diff --git a/tests/web-backend.php b/tests/web-backend.php new file mode 100644 index 0000000..05e9009 --- /dev/null +++ b/tests/web-backend.php @@ -0,0 +1,59 @@ +&1', $output, $exitCode); +if($exitCode!==0 || !is_file($tmp.'/catalog/parameters.json')) throw new RuntimeException('Could not generate parameter catalog: '.implode("\n",$output)); +$owner=$config['workspace']; +function check_backend($value,$message){if(!$value)throw new RuntimeException($message);} +file_put_contents($tmp.'/config.json',json_encode($config)); putenv('EXOPLASIM_CONFIG='.$tmp.'/config.json'); +try { + $saved=Experiment::save($owner,['name'=>'test','spec'=>['model'=>['resolution'=>'T21','layers'=>10,'ncpus'=>1],'parameters'=>[],'run'=>['cycles'=>1,'steps'=>1440]]]); + if(empty($saved['ok'])) throw new RuntimeException('Experiment save failed: '.json_encode($saved)); + check_backend(is_object($saved['data']['spec']['parameters']),'Saved experiment API must encode empty parameters as an object.'); + $query=LabDb::connection()->prepare('SELECT spec FROM experiments WHERE id=?');$query->execute([$saved['data']['id']]);$raw=$query->fetchColumn(); + check_backend(is_object(json_decode($raw)->parameters),'Saved experiment JSON must contain an empty object for parameters.'); + $job=Job::enqueue($owner,$saved['data']); check_backend($job['ok'],'Model queue failed.'); + check_backend(is_object($job['data']['spec']['parameters']),'Job API must encode empty parameters as an object.'); + check_backend(Job::get($job['data']['id'],'other-workspace')===null,'Job ownership must be scoped.'); + check_backend(Job::cancel($job['data']['id'],$owner)['ok'],'Model cancellation failed.'); + $modelRetry=Job::retry($job['data']['id'],$owner); + check_backend($modelRetry['ok']&&$modelRetry['data']['kind']==='model','Model retry must preserve kind.'); + $mapBytes=pack('g*',...array_fill(0,2048,100.0)); + $height=Maps::save('height','T21','height',$mapBytes);check_backend($height['ok'],'Height map save failed.'); + $mapSpec=['model'=>['resolution'=>'T21'],'maps'=>['height'=>$height['data']['id']]]; + $capture=Maps::captures($mapSpec);check_backend($capture['ok']&&hash('sha256',$capture['data']['height']['data'])===$height['data']['digest'],'Map capture must preserve immutable bytes.'); + $land=Maps::save('landmask','T21','land',pack('g*',...array_fill(0,2048,1.0)));check_backend($land['ok'],'Land map save failed.'); + $mapped=Experiment::save($owner,['name'=>'mapped','spec'=>['model'=>['resolution'=>'T21'],'parameters'=>[],'run'=>['cycles'=>1,'steps'=>1440],'maps'=>['landmask'=>$land['data']['id'],'height'=>$height['data']['id']]]]); + $mappedJob=Job::enqueue($owner,$mapped['data']);check_backend($mappedJob['ok'],'Mapped job enqueue failed.'); + $captured=Job::mapCaptures($mappedJob['data']['id']);check_backend($captured['height']['data']===$mapBytes,'Job must capture exact map bytes.');check_backend(Job::validatedMapCaptures($mappedJob['data'])['ok'],'Map capture validation must not depend on JSON key order.'); + $badCapture=$mappedJob['data'];$badCapture['spec']['maps']=[];check_backend(empty(Job::validatedMapCaptures($badCapture)['ok']),'Missing map capture rows must fail validation.'); + $changed=Maps::save('height','T21','later edit',pack('g*',...array_fill(0,2048,200.0)));check_backend($changed['ok']&&Job::mapCaptures($mappedJob['data']['id'])['height']['data']===$mapBytes,'Later map edits must not change queued job inputs.'); + check_backend(Job::cancel($mappedJob['data']['id'],$owner)['ok'],'Mapped cancellation failed.');$mappedRetry=Job::retry($mappedJob['data']['id'],$owner);check_backend($mappedRetry['ok']&&Job::mapCaptures($mappedRetry['data']['id'])['height']['data']===$mapBytes,'Retry must copy the original immutable map capture.'); + $invalid=Maps::save('landmask','T21','bad',pack('g*',...array_fill(0,2048,0.5)));check_backend(empty($invalid['ok']),'Non-binary land mask must fail.'); + check_backend(empty(Maps::save('height','T21','wrong size',pack('g*',1.0))['ok']),'Wrong map dimensions must fail.'); + $nonfinite=Maps::save('height','T21','nonfinite',pack('g*',...array_merge([NAN],array_fill(0,2047,0.0))));check_backend(empty($nonfinite['ok']),'Nonfinite map values must fail.'); + $otherResolution=Maps::save('height','T42','other grid',pack('g*',...array_fill(0,8192,10.0)));check_backend($otherResolution['ok'],'T42 map save failed.'); + $cross=Experiment::save($owner,['name'=>'cross','spec'=>['model'=>['resolution'=>'T21'],'maps'=>['height'=>$otherResolution['data']['id']]]]);check_backend(empty(Job::enqueue($owner,$cross['data'])['ok']),'Cross-resolution map enqueue must fail.'); + $planet=Job::enqueueKind($owner,'planet','generated',['model'=>[],'parameters'=>[],'run'=>['cycles'=>5],'surface'=>['seed'=>12]]); + check_backend($planet['ok'],'Planet enqueue failed.'); + check_backend($planet['ok']&&$planet['data']['kind']==='planet'&&(int)$planet['data']['total_cycles']===1,'Planet jobs must have one generation cycle.'); + check_backend(is_object($planet['data']['spec']['parameters']),'Planet job API must encode empty parameters as an object.'); + check_backend(Job::cancel($planet['data']['id'],$owner)['ok'],'Planet cancellation failed.'); + $planetRetry=Job::retry($planet['data']['id'],$owner); + check_backend($planetRetry['ok']&&$planetRetry['data']['kind']==='planet'&&(int)$planetRetry['data']['total_cycles']===1,'Planet retry must preserve kind and one generation cycle.'); + check_backend(empty(Job::enqueueKind($owner,'arbitrary','bad',[])['ok']),'Unsupported task kind must fail.'); + echo "backend PHP tests passed\n"; +} finally { + putenv('EXOPLASIM_CONFIG'); + foreach (glob($tmp.'/jobs/*')?:[] as $f) { if(is_dir($f)) rmdir($f); else unlink($f); } + if (is_dir($tmp.'/jobs')) rmdir($tmp.'/jobs'); + foreach (glob($tmp.'/data/*')?:[] as $f) if(is_file($f)) unlink($f); + if(is_dir($tmp.'/data')) rmdir($tmp.'/data'); + foreach (glob($tmp.'/catalog/*')?:[] as $f) if(is_file($f)) unlink($f); + if(is_dir($tmp.'/catalog')) rmdir($tmp.'/catalog'); + foreach (glob($tmp.'/*')?:[] as $f) if(is_file($f)) unlink($f); + rmdir($tmp); +} diff --git a/tests/web-composite.js b/tests/web-composite.js new file mode 100644 index 0000000..ab4c836 --- /dev/null +++ b/tests/web-composite.js @@ -0,0 +1,40 @@ +'use strict'; +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const context = { window: {} }; +vm.runInNewContext(fs.readFileSync('web/js/composite.js', 'utf8'), context); +const api = context.window.ExoComposite; +const index = { fields: [ + { name: 'ts', unit: 'K', shape: [3, 1, 2, 2], min: 200, max: 300 }, + { name: 'pr', unit: 'm s-1', shape: [3, 1, 2, 2], min: 0, max: 1e-6 }, + { name: 'sg', unit: 'm2 s-2', shape: [1, 1, 2, 2], min: -981, max: 981 } +] }; +const descriptor = api.create(index, 9.81); +assert.ok(descriptor); +const mismatched = structuredClone(index);mismatched.fields[1].shape[2] = 3; +assert.equal(api.create(mismatched, 9.81), null); +const reversed = structuredClone(index);reversed.fields[1].max = -1; +assert.equal(api.create(reversed, 9.81), null); +assert.equal(api.create(index, Number.MIN_VALUE), null); +assert.deepEqual(Array.from(descriptor.channels, c => [c.channel, c.name]), [['red', 'ts'], ['green', 'pr'], ['blue', 'sg']]); +assert.equal(descriptor.channels[1].label, 'Precipitation'); +assert.equal(descriptor.channels[1].unit, 'mm/day'); +assert.equal(api.normalise(250, descriptor.channels[0]), 0.5); +assert.equal(api.normalise(0.5e-6, descriptor.channels[1]), 0.5); +assert.equal(api.normalise(0, descriptor.channels[2]), 0.5); +assert.equal(api.normalise(-10000, descriptor.channels[2]), 0); +assert.equal(api.normalise(1000, descriptor.channels[2]), 1); +assert.equal(api.normalise(NaN, descriptor.channels[0]), null); +assert.equal(api.create(index, 0), null); +assert.equal(api.create(index, Infinity), null); +assert.equal(api.create({ fields: index.fields.filter(field => field.name !== 'pr') }, 9.81), null); +assert.equal(api.create({ fields: index.fields.map(field => field.name === 'ts' ? { ...field, unit: 'm' } : field) }, 9.81), null); +assert.equal(api.create({ fields: index.fields.map(field => field.name === 'ts' ? { ...field, shape: [3, 2, 2, 2] } : field) }, 9.81), null); +const constant = { fields: index.fields.map(field => field.name === 'pr' ? { ...field, min: 0, max: 0 } : field) }; +const constantDescriptor = api.create(constant, 9.81); +assert.equal(api.normalise(0, constantDescriptor.channels[1]), 0); +const constantNonzero = { fields: index.fields.map(field => field.name === 'pr' ? { ...field, min: 1e-6, max: 1e-6 } : field) }; +assert.equal(api.normalise(1e-6, api.create(constantNonzero, 9.81).channels[1]), 0.5); +assert.equal(api.normalise(2e-6, descriptor.channels[1]), 1); +console.log('web-composite: ok'); diff --git a/tests/web-gui-composite.js b/tests/web-gui-composite.js new file mode 100644 index 0000000..ac93ba9 --- /dev/null +++ b/tests/web-gui-composite.js @@ -0,0 +1,25 @@ +(async()=>{ +'use strict'; +const check=(condition,message)=>{if(!condition)throw Error(message);},wait=async condition=>{const limit=performance.now()+3000;while(!condition()){if(performance.now()>limit)throw Error('Composite rendering timed out.');await new Promise(resolve=>setTimeout(resolve,10));}}; +const schema=(await(await fetch(window.EXOPLASIM.api.schema)).json()).data,grid=schema.map_grids.T85; +const root=document.createElement('div');root.style.cssText='position:fixed;left:-10000px;top:0;width:640px;height:480px';document.body.append(root); +const original=window.fetch,frames=new Map(),rows=grid.lat.length,cols=grid.lon.length,gravity=schema.fields.find(field=>field.name==='gravity').implicit_default; +const index={lat:grid.lat,lon:grid.lon,time:[0,1],time_unit:'timesteps',fields:[{name:'ts',label:'surface_temperature',unit:'K',shape:[2,1,rows,cols],min:200,max:300},{name:'pr',label:'total_precipitation',unit:'m s-1',shape:[2,1,rows,cols],min:0,max:1e-6},{name:'sg',label:'surface_geopotential',unit:'m2 s-2',shape:[1,1,rows,cols],min:-1000,max:1000}]}; +for(const [i,field] of index.fields.entries()){const values=new Float32Array(rows*cols);for(let cell=0;cell{const name=String(url);if(!name.startsWith('exo-composite-probe:'))return original(url,options);const field=name.split(':')[1];if(field==='series')return Promise.resolve(new Response(JSON.stringify({ok:true,data:{mean:[250,251],unit:'K',time:index.time,time_unit:index.time_unit}})));return Promise.resolve(new Response(frames.get(field),{status:fail&&field==='pr'?500:200}));}; + visual=new ExoVisualization(root);visual.setDataset(index,field=>'exo-composite-probe:'+field,()=> 'exo-composite-probe:series',{gravity}); + await wait(()=>root.querySelector('.viz-empty').hidden); + const view=root.querySelector('[data-view]'),time=root.querySelector('[data-time]');view.value='rgb';view.dispatchEvent(new Event('change'));await wait(()=>root.querySelector('.viz-empty').hidden); + check(root.querySelectorAll('.viz-rgb-legend>div').length===3,'Three channel legends are required.'); + const legend=root.querySelector('.viz-rgb-legend').textContent,timings=[]; + for(let trial=0;trial<42;trial++){const start=performance.now();await new Promise((resolve,reject)=>{const status=root.querySelector('.viz-empty'),observer=new MutationObserver(()=>{if(status.hidden){observer.disconnect();clearTimeout(timeout);resolve();}}),timeout=setTimeout(()=>{observer.disconnect();reject(Error('Composite timing sample timed out.'));},3000);observer.observe(status,{attributes:true,attributeFilter:['hidden']});time.value=String(trial%2);time.dispatchEvent(new Event('input'));});if(trial>=2)timings.push(performance.now()-start);} + check(root.querySelector('.viz-rgb-legend').textContent===legend,'Playback must not change the channel scales.'); + const map=root.querySelector('.viz-map');map.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true}));check(root.querySelector('[data-inspector]').textContent.includes('mm/day'),'Inspection must show converted precipitation.'); + fail=true;time.dispatchEvent(new Event('input'));await wait(()=>root.querySelector('.viz-empty').textContent.includes('500'));check(root.querySelector('[data-inspector]').textContent.includes('500'),'A channel failure must replace stale inspection.'); + fail=false;time.dispatchEvent(new Event('input'));await wait(()=>root.querySelector('.viz-empty').hidden); + view.value='field';view.dispatchEvent(new Event('change'));await wait(()=>root.querySelector('.viz-empty').hidden);check(root.querySelector('.viz-rgb-legend').hidden,'Single-field mode must recover.'); + timings.sort((a,b)=>a-b);return{passed:true,cells:rows*cols,repetitions:timings.length,mean_ms:timings.reduce((a,b)=>a+b)/timings.length,p95_ms:timings[Math.ceil(timings.length*.95)-1],max_ms:timings.at(-1),timing_scope:'Synthetic fetch, decoding and synchronous rendering, timed to the completion mutation; not a compositor measurement.'}; +}finally{window.fetch=original;visual?.destroy();root.remove();} +})() diff --git a/tests/web-gui-editor.js b/tests/web-gui-editor.js new file mode 100644 index 0000000..dbd9c70 --- /dev/null +++ b/tests/web-gui-editor.js @@ -0,0 +1,55 @@ +(async () => { + const report={ok:false,checks:[],benchmark:null}; + const check=(name,passed)=>{report.checks.push({name,ok:Boolean(passed)});if(!passed)throw Error(name);}; + const wait=async predicate=>{const deadline=performance.now()+2000;while(!predicate()){if(performance.now()>deadline)throw Error('Editor did not settle within two seconds.');await new Promise(resolve=>setTimeout(resolve,10));}}; + const focused=document.activeElement,scroll=[scrollX,scrollY],root=document.createElement('div'); + root.style.cssText='position:fixed;left:-10000px;width:640px';document.body.append(root); + let editor,bench,releaseLoad,releaseSave; + try { + const abort=new AbortController(),timer=setTimeout(()=>abort.abort(),2000); + let schema; + try{const response=await fetch('?api=schema',{signal:abort.signal});check('Schema loads',response.ok);schema=(await response.json()).data;}finally{clearTimeout(timer);} + const fields=schema.map_fields,grid=schema.map_grids.T21; + const values=(field,g=grid)=>new Float32Array(g.lat.length*g.lon.length).fill(fields[field].default); + let mode='normal'; + const callbacks={fields,onDirty:()=>{},onSaved:()=>{},sources:async()=>[{id:'fixture',name:'Local test fixture'}], + load:async field=>{if(mode==='fail')throw Error('Test load failure');if(mode==='hold')await new Promise(resolve=>{releaseLoad=resolve;});return{values:values(field),grid,name:'Test map'};}, + save:async field=>{if(mode==='save-fail')throw Error('Test save failure');if(mode==='save-hold')await new Promise(resolve=>{releaseSave=resolve;});return{id:'test-only',field};}}; + editor=new ExoMapEditor(root,callbacks);await editor.start(); + const el=selector=>root.querySelector(selector),canvas=el('canvas'); + const paint=()=>canvas.dispatchEvent(new KeyboardEvent('keydown',{key:' ',bubbles:true,cancelable:true})); + const change=async field=>{el('[data-map-field]').value=field;el('[data-map-field]').dispatchEvent(new Event('change'));await new Promise(resolve=>setTimeout(resolve,0));await wait(()=>!el('[data-map-field]').disabled);}; + check('Initial map is clean',el('[data-map-save]').disabled); + paint();check('Keyboard paint is dirty',!el('[data-map-save]').disabled); + el('[data-map-undo]').click();check('Undo returns to original',el('[data-map-save]').disabled); + el('[data-map-redo]').click();check('Redo restores edit',!el('[data-map-save]').disabled); + el('[data-map-undo]').click();await change('landmask'); + check('Categorical controls are visible',!el('[data-map-ocean]').hidden&&!el('[data-map-land]').hidden); + el('[data-map-ocean]').click();check('Ocean selects zero',Number(el('[data-map-value]').value)===fields.landmask.min); + el('[data-map-land]').click();check('Land selects one',Number(el('[data-map-value]').value)===fields.landmask.max); + el('[data-map-value]').value='.5';paint();check('Fractional mask is not painted',el('[data-map-save]').disabled&&el('[data-map-status]').textContent==='Choose Ocean or Land.'); + el('[data-map-land]').click();paint();check('Land brush paints',!el('[data-map-save]').disabled); + el('[data-map-undo]').click();await change('albedo'); + el('[data-map-tool]').value='set';el('[data-map-value]').value=String(fields.albedo.max+1);paint(); + check('Out-of-range albedo is not painted',el('[data-map-save]').disabled&&el('[data-map-status]').textContent.startsWith('Value must be')); + el('[data-map-value]').value='.15';paint();mode='save-fail';el('[data-map-save]').click();await wait(()=>el('[data-map-status]').textContent==='Test save failure'); + check('Failed save retains changes',!el('[data-map-save]').disabled); + mode='save-hold';el('[data-map-save]').click();await wait(()=>Boolean(releaseSave)); + check('Saving cannot close',!editor.canClose());releaseSave();await wait(()=>el('[data-map-status]').textContent.startsWith('Map saved')); + check('Successful save becomes clean',el('[data-map-save]').disabled); + mode='fail';await change('height');check('Load failure clears old values',el('[data-map-position]').textContent===''&&el('[data-map-min]').textContent===''&&el('[data-map-save]').disabled); + mode='normal';await change('albedo');check('Load recovers',Boolean(el('[data-map-position]').textContent)); + mode='hold';el('[data-map-field]').value='height';el('[data-map-field]').dispatchEvent(new Event('change'));await wait(()=>Boolean(releaseLoad)); + check('Loading can close',editor.canClose());editor.destroy();releaseLoad();await new Promise(resolve=>setTimeout(resolve,0)); + check('Destroyed editor ignores delayed load',root.childElementCount===0); + const benchRoot=document.createElement('div');root.append(benchRoot);const g=schema.map_grids.T85; + bench=new ExoMapEditor(benchRoot,{...callbacks,fields:{height:fields.height},load:async()=>({values:values('height',g),grid:g})});await bench.start(); + const times=[],c=benchRoot.querySelector('canvas'); + for(let i=0;i<40;i++){const start=performance.now();c.dispatchEvent(new KeyboardEvent('keydown',{key:' ',bubbles:true,cancelable:true}));times.push(performance.now()-start);} + times.sort((a,b)=>a-b);report.benchmark={resolution:'T85',cells:g.nlat*g.nlon,repetitions:times.length,meanMs:times.reduce((a,b)=>a+b)/times.length,p95Ms:times[37],maxMs:times.at(-1),compositorIncluded:false}; + check('T85 benchmark uses its actual grid',g.lat.length===g.nlat&&g.lon.length===g.nlon&&g.nlat>grid.nlat); + report.ok=true; + }catch(error){report.error=String(error.stack||error);} + finally{releaseLoad?.();releaseSave?.();editor?.destroy();bench?.destroy();root.remove();if(focused?.isConnected)focused.focus({preventScroll:true});window.scrollTo(...scroll);} + return report; +})() diff --git a/tests/web-gui-numbers.py b/tests/web-gui-numbers.py new file mode 100644 index 0000000..1346f17 --- /dev/null +++ b/tests/web-gui-numbers.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +import argparse +import importlib.util +import json +import time + +parser = argparse.ArgumentParser() +parser.add_argument('--helper', required=True) +parser.add_argument('--target', required=True) +args = parser.parse_args() +module = importlib.util.spec_from_file_location('browser', args.helper) +browser = importlib.util.module_from_spec(module) +module.loader.exec_module(browser) + +with browser.CDP(browser.page_ws_url(browser.DEFAULT_PORT, args.target), timeout=5) as cdp: + state = cdp.eval("({dirty:document.querySelector('#save-state').classList.contains('dirty'),preset:document.querySelector('[data-experiment][aria-current=true]')?.dataset.experiment})") + assert not state['dirty'] and state.get('preset'), 'Select a clean saved preset before this test.' + cdp.eval("window.__exoNumberFetch=window.fetch;window.__exoNumberPosts=0;window.fetch=(url,options={})=>{if(options.method==='POST'&&new URL(String(url),location.href).searchParams.get('api')==='experiments'){window.__exoNumberPosts++;return Promise.reject(Error('Invalid-number probe attempted a save.'));}return window.__exoNumberFetch(url,options);};") + checks = [] + try: + for selector in ['[data-param=gravity]', '#run-steps']: + cdp.eval(f"(()=>{{const control=document.querySelector({json.dumps(selector)});control.focus();control.select();}})()") + cdp.call('Input.insertText', {'text': '1e'}) + result = cdp.eval(f"(()=>{{const control=document.querySelector({json.dumps(selector)});return{{badInput:control.validity.badInput,defaultChecked:document.querySelector('[data-null=gravity]').checked}};}})()") + assert result['badInput'], result + if selector == '[data-param=gravity]': + assert not result['defaultChecked'], 'An incomplete number must not select the default.' + cdp.eval("document.querySelector('[data-action=save]').click()") + time.sleep(0.1) + assert cdp.eval('window.__exoNumberPosts') == 0, 'Invalid input reached persistence.' + checks.append(selector) + cdp.eval("(()=>{const original=window.confirm;try{window.confirm=()=>true;document.querySelector('[data-experiment=\"" + state['preset'] + "\"]').click();}finally{window.confirm=original;}})()") + print(json.dumps({'passed': True, 'nativeKeyboardChecks': checks, 'mutationRequests': 0})) + finally: + cdp.eval("window.fetch=window.__exoNumberFetch;delete window.__exoNumberFetch;delete window.__exoNumberPosts;") + cdp.eval("(()=>{const original=window.confirm;try{window.confirm=()=>true;document.querySelector('[data-experiment=\"" + state['preset'] + "\"]').click();}finally{window.confirm=original;}})()") diff --git a/tests/web-gui-workbench.js b/tests/web-gui-workbench.js new file mode 100644 index 0000000..9aae50d --- /dev/null +++ b/tests/web-gui-workbench.js @@ -0,0 +1,40 @@ +(async()=>{ +'use strict'; +const $=selector=>document.querySelector(selector),check=(value,message)=>{if(!value)throw Error(message);},wait=async condition=>{const deadline=performance.now()+4000;while(!condition()){if(performance.now()>deadline)throw Error('Workbench regression timed out.');await new Promise(resolve=>setTimeout(resolve,10));}},click=action=>$(`[data-action="${action}"]`).click(),name=value=>{const input=$('#experiment-name');input.value=value;input.dispatchEvent(new Event('input',{bubbles:true}));}; +check(!$('#save-state').classList.contains('dirty'),'Do not run this fixture with unsaved user edits.'); +const original=window.fetch,confirm=window.confirm,fixtures=new Set(),prefix='GUI response '+Date.now(),initialJob=$('[data-job][aria-current=true]')?.dataset.job,initialPreset=$('[data-experiment][aria-current=true]')?.dataset.experiment||$('[data-experiment]')?.dataset.experiment; +let saved,holdDelete=false,releaseDelete=null,failLists=true,listReads=0,mockQueue=false,queueReads=0; +const json=value=>new Response(JSON.stringify({ok:true,data:value}),{headers:{'Content-Type':'application/json'}}); +try{ + window.confirm=()=>true; + window.fetch=async(url,options={})=>{ + const api=new URL(String(url),location.href).searchParams.get('api'),method=options.method||'GET'; + if(api==='experiments'&&method==='GET'&&failLists){listReads++;throw Error('Injected experiment-list failure.');} + if(api==='jobs'&&method==='GET'&&mockQueue){queueReads++;throw Error('Injected job-list failure.');} + if(api==='jobs'&&method==='POST'&&mockQueue)return json({id:'f'.repeat(32),experiment_id:saved.id,name:saved.name,spec:saved.spec,kind:'model',status:'queued',phase:'queued',progress:0,total_cycles:saved.spec.run.cycles,completed_cycles:0,created_at:new Date().toISOString()}); + const response=await original(url,options); + if(api==='experiments'&&method==='POST'&&response.ok){const data=(await response.clone().json()).data;saved=data.experiment||data;fixtures.add(saved.id);} + if(api==='experiment_delete'&&method==='POST'&&holdDelete){await new Promise(resolve=>{releaseDelete=resolve;});} + return response; + }; + $(`[data-experiment="${initialPreset}"]`).click();click('clone');name(prefix+' A');click('save');await wait(()=>saved&&$('#save-state').textContent==='Saved');const a=saved.id; + check(listReads===0,'A successful save must not depend on a list request.');check($(`[data-experiment="${a}"]`),'The saved record must appear in the local list.'); + click('clone');name(prefix+' B');click('save');await wait(()=>saved.id!==a&&$('#save-state').textContent==='Saved');const b=saved.id; + $(`[data-experiment="${a}"]`).click();holdDelete=true;click('delete');await wait(()=>releaseDelete!==null); + $(`[data-experiment="${b}"]`).click();name(prefix+' B pending edit');releaseDelete();releaseDelete=null;await wait(()=>!$(`[data-experiment="${a}"]`)); + check($('#experiment-name').value===prefix+' B pending edit','An old delete response must not replace the selected draft.');check($(`[data-experiment="${b}"]`),'Deleting A must not remove B from the local list.'); + click('delete');await wait(()=>releaseDelete!==null);name(prefix+' recovered edits');releaseDelete();releaseDelete=null;await wait(()=>!$(`[data-experiment="${b}"]`)); + check($('#experiment-name').value===prefix+' recovered edits','Edits made during deletion must survive.');check($('#save-state').classList.contains('dirty'),'Recovered edits must remain unsaved.'); + holdDelete=false;click('save');await wait(()=>saved.id!==b&&$('#save-state').textContent==='Saved');check(saved.id!==a,'A recovered draft must create a new saved record.'); + mockQueue=true;click('save-run');await wait(()=>$('#workbench-notice').textContent==='Run added to queue.'); + check($('[data-job="'+ 'f'.repeat(32)+'"]'),'The mutation response must populate the job list.');check(queueReads===0,'Queue success must not depend on a job-list request.'); + return{passed:true,realSavedFixtures:fixtures.size,saveListReads:listReads,queueListReads:queueReads,queueResponse:'mocked; no scientific job was created',checks:['save response cache','delayed delete across preset selection','edits during deletion','recovered draft save','queue response cache']}; +}finally{ + holdDelete=false;releaseDelete?.();window.fetch=original;mockQueue=false;failLists=false; + for(const id of fixtures){const response=await original(window.EXOPLASIM.api.experiment_delete,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})});if(!response.ok&&response.status!==404)console.error('Fixture deletion failed:',id,response.status);} + click('refresh');await new Promise(resolve=>setTimeout(resolve,200)); + const preset=$(`[data-experiment="${initialPreset}"]`);if(preset)preset.click(); + const job=$(`[data-job="${initialJob}"]`);if(job)job.click(); + window.confirm=confirm; +} +})() diff --git a/tests/web-http.php b/tests/web-http.php new file mode 100644 index 0000000..b0ee4a6 --- /dev/null +++ b/tests/web-http.php @@ -0,0 +1,11 @@ +[],'run'=>['steps'=>null],'parameters'=>[]]); +if(empty($valid['ok'])||$valid['spec']['run']['steps']!==null) throw new RuntimeException('Explicit full-year steps were not preserved.'); +$invalid=ParameterCatalog::validate(['model'=>[],'run'=>[],'parameters'=>[],'command'=>[]]); +if(!empty($invalid['ok'])||!isset($invalid['errors']['command'])) throw new RuntimeException('Unknown spec sections were accepted.'); +echo "single-workspace HTTP boundary checks passed\n"; diff --git a/tests/web-installer.py b/tests/web-installer.py new file mode 100644 index 0000000..1b9ce3c --- /dev/null +++ b/tests/web-installer.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Regression checks for isolated web-installer paths.""" +import fcntl +import json +import os +from pathlib import Path +import shutil +import sqlite3 +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +INSTALLER = ROOT / 'scripts/install-web' + + +class WebInstallerTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + source = json.loads((ROOT / 'config/web.json').read_text()) + source['public_root'] = str(self.base / 'public') + source['storage']['root'] = str(self.base / 'private') + source['storage']['database'] = 'db/lab.sqlite3' + source['storage']['jobs'] = 'queued-jobs' + source['storage']['runtime'] = 'runtime-files' + source['storage']['catalog'] = 'catalog/parameters.json' + config_dir = self.base / 'alternate' / 'config' + config_dir.mkdir(parents=True) + self.config = config_dir / 'web.json' + self.config.write_text(json.dumps(source)) + self.bin = self.base / 'bin' + self.bin.mkdir() + self.log = self.base / 'commands.log' + script = self.bin / 'command-stub' + script.write_text('#!/bin/sh\nprintf "%s\\n" "$(basename "$0") $*" >> "$INSTALLER_TEST_LOG"\nexit 97\n') + script.chmod(0o755) + self.commands = ['systemctl', 'install', 'rsync', 'chown', 'chmod', 'getent', 'useradd', 'setfacl'] + for name in self.commands: + (self.bin / name).symlink_to(script) + self.env = os.environ.copy() + self.env.update(PATH=f"{self.bin}:{self.env['PATH']}", INSTALLER_TEST_LOG=str(self.log)) + + def run_installer(self, *args): + return subprocess.run([str(INSTALLER), *args], cwd=self.base / 'alternate', env={**self.env, 'EXOPLASIM_CONFIG': str(self.config)}, capture_output=True, text=True, timeout=15) + + def test_dry_run_uses_alternate_config_from_other_working_directory_without_mutation(self): + before = {p.relative_to(self.base) for p in self.base.rglob('*')} + result = self.run_installer('--dry-run') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(str(self.base / 'public'), result.stdout) + self.assertIn(str(self.base / 'private'), result.stdout) + self.assertFalse(self.log.exists(), 'dry-run invoked a potentially mutating command') + self.assertEqual(before, {p.relative_to(self.base) for p in self.base.rglob('*')}, 'dry-run changed the fixture') + + def test_active_jobs_block_runtime_changes_even_when_worker_is_inactive(self): + database = self.base / 'private' / 'db' / 'lab.sqlite3' + database.parent.mkdir(parents=True) + with sqlite3.connect(database) as db: + db.executescript((ROOT / 'web/server/schema.sql').read_text()) + db.execute("INSERT INTO jobs(id,owner,name,spec,status,total_cycles,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)", ('a' * 32, 'local', 'fixture', '{}', 'queued', 1, 'now', 'now')) + for status in ('queued', 'running'): + with self.subTest(status=status): + with sqlite3.connect(database) as db: + db.execute('UPDATE jobs SET status=?', (status,)) + result = self.run_installer() + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn('Refusing', result.stderr) + self.assertFalse(self.log.exists(), 'installer invoked external mutation before refusal') + with sqlite3.connect(database) as db: + self.assertEqual(db.execute('SELECT status FROM jobs').fetchone()[0], status) + + def test_another_installation_blocks_runtime_changes(self): + result = subprocess.run([shutil.which('php'), '-r', 'require $argv[1]."/web/config/lab.php";echo lab_config()["runtime_lock"];', str(ROOT)], env={**self.env, 'EXOPLASIM_CONFIG': str(self.config)}, capture_output=True, text=True, timeout=5, check=True) + lock = Path(result.stdout) + lock.parent.mkdir(parents=True) + with lock.open('w') as stream: + fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB) + result = self.run_installer() + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn('installation or job claim is in progress', result.stderr) + self.assertFalse(self.log.exists(), 'a concurrent installation invoked external mutation') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/web-live-http.py b/tests/web-live-http.py new file mode 100644 index 0000000..637c6bd --- /dev/null +++ b/tests/web-live-http.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Exercise the running PHP workbench without cookies or security headers.""" +import argparse +import json +import math +import re +import struct +import urllib.error +import urllib.parse +import urllib.request +import uuid + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +class Client: + def __init__(self, base): + self.base = base.rstrip('/') + '/' + self.opener = urllib.request.build_opener(NoRedirect()) + self.count = 0 + + def request(self, query='', data=None, binary=False, status=200): + self.count += 1 + assert self.count <= 45, 'Request limit exceeded.' + headers = {} + if data is not None: + headers['Content-Type'] = 'application/octet-stream' if binary else 'application/json' + if not binary: + data = json.dumps(data).encode() + request = urllib.request.Request(self.base + query, data=data, headers=headers) + try: + response = self.opener.open(request, timeout=8) + except urllib.error.HTTPError as error: + response = error + with response: + body = response.read(1048577) + assert len(body) <= 1048576, 'Response exceeds test read limit.' + assert response.code == status, (response.code, body[:300]) + assert response.geturl() == request.full_url, 'Unexpected redirect.' + assert not response.headers.get('Set-Cookie'), 'Unexpected session cookie.' + return body, response.headers + + def api(self, operation, query=None, data=None, binary=False, status=200): + params = {'api': operation, **(query or {})} + return self.request('?' + urllib.parse.urlencode(params), data, binary, status) + + def json(self, operation, **kwargs): + raw, _ = self.api(operation, **kwargs) + value = json.loads(raw) + assert value.get('ok') is True, value + return value['data'] + + +def run(base): + client = Client(base) + html, _ = client.request() + match = re.search(rb'window\.EXOPLASIM\s*=\s*(\{.*?\})\s*;', html, re.S) + assert match and set(json.loads(match[1])) == {'api'}, 'Unexpected bootstrap/session data.' + schema = client.json('schema') + assert schema['fields'] and schema['map_fields'] + client.api('unknown', status=404) + suffix = uuid.uuid4().hex[:12] + fixtures, versions = [], [] + try: + saved = client.json('experiments', data={'name': 'HTTP-' + suffix, 'spec': {'model': {}, 'run': {}, 'parameters': {}}}) + fixtures.append(saved['id']) + clone = client.json('experiments', data={'name': 'HTTP-copy-' + suffix, 'spec': saved['spec']}) + fixtures.append(clone['id']) + exported = json.loads(json.dumps({'name': 'HTTP-import-' + suffix, 'spec': clone['spec']})) + imported = client.json('experiments', data=exported) + fixtures.append(imported['id']) + assert imported['spec'] == saved['spec'] + client.api('experiments', data={'id': saved['id'], 'name': saved['name'], 'spec': {'invalid': True}}, status=422) + retained = next(row for row in client.json('experiments') if row['id'] == saved['id']) + assert retained == saved, 'Failed save changed the stored preset.' + resolution = next(iter(schema['map_grids'])) + grid = schema['map_grids'][resolution] + count = grid['nlat'] * grid['nlon'] + payload = struct.pack('isFile())continue; + $relative=substr($file->getPathname(),strlen($public)+1); + if($relative==='index.php')continue; + if(!preg_match('~^(js/[^/]+\.js|themes/exoplasim/css/[^/]+\.css)$~',$relative))throw new RuntimeException('Unexpected public file: '.$relative); +} +$installer=file_get_contents(__DIR__.'/../scripts/install-web'); +if(preg_match('~(/etc/nginx|render_nginx|systemctl[^\n]*nginx)~',$installer))throw new RuntimeException('Installer must not configure nginx.'); +if(is_file(__DIR__.'/../deploy/exoplasim-nginx.conf'))throw new RuntimeException('Application nginx configuration remains.'); +echo "Public-file boundary and ordinary PHP hosting checks passed\n"; diff --git a/tests/web-worker.php b/tests/web-worker.php new file mode 100644 index 0000000..2e14024 --- /dev/null +++ b/tests/web-worker.php @@ -0,0 +1,64 @@ +$tmp,'database'=>'data/lab.sqlite3','jobs'=>'jobs','runtime'=>'runtime','catalog'=>'catalog/schema.json']; +foreach(['data','jobs','runtime','catalog'] as $dir) mkdir($tmp.'/'.$dir,0750,true); +$schema=['fields'=>[],'model'=>[],'run'=>[],'model_defaults'=>[],'run_defaults'=>[]];file_put_contents($tmp.'/catalog/schema.json',json_encode($schema)); +$science=$tmp.'/science';mkdir($science,0750,true); +$script=<<<'PY' +import argparse,json,os,pathlib +p=argparse.ArgumentParser();p.add_argument('--config');p.add_argument('--job');a=p.parse_args();j=json.load(open(a.job));print('ENV_MARKER='+os.environ.get('EXOPLASIM_TEST_INHERIT','missing'),flush=True);d=('e' if j['kind']=='planet' else 'f')*32;root=pathlib.Path(j['directory'])/'artifacts'/'datasets'/d;root.mkdir(parents=True,exist_ok=True);json.dump({'id':d,'fields':[]},open(root/'index.json','w'));event={'cycle':1,'cycles':1,'dataset':d};event.update({'planet':True,'spec':j['spec']} if j['kind']=='planet' else {});print(json.dumps(event),flush=True) +PY; +file_put_contents($science.'/model.py',$script);file_put_contents($science.'/planet.py',$script); +$config=['workspace'=>'local','storage'=>$storage,'python'=>'/usr/bin/python3','science_scripts'=>$science,'project_root'=>dirname(__DIR__),'poll_seconds'=>1,'job_timeout_seconds'=>60,'minimum_free_bytes'=>0,'queue_limit'=>10]; +file_put_contents($tmp.'/config.json',json_encode($config));putenv('EXOPLASIM_CONFIG='.$tmp.'/config.json'); +function expect($condition,$message){if(!$condition)throw new RuntimeException($message);} +try { + $worker=new LabWorker(); + expect($worker->acquire(),'First worker must acquire flock.'); + $second=new LabWorker();expect(!$second->acquire(),'Second worker must not acquire flock.'); + $db=LabDb::connection();$now=gmdate('c'); + $db->exec("INSERT INTO jobs(id,owner,name,spec,status,total_cycles,created_at,updated_at) VALUES('".str_repeat('a',32)."','local','bad','{\"invalid\":true}','queued',1,'$now','$now')"); + $installation=fopen(lab_config()['runtime_lock'],'c');flock($installation,LOCK_EX); + expect($worker->claim()===null,'Installation lock must prevent a worker claim.'); + expect($db->query("SELECT status FROM jobs WHERE id='".str_repeat('a',32)."'")->fetchColumn()==='queued','Installation must leave the queued job intact.'); + fclose($installation); + $claimed=$worker->claim();expect($claimed['id']===str_repeat('a',32),'Worker must claim queued job after installation lock is released.'); + $worker->recover();$status=$db->query("SELECT status FROM jobs WHERE id='".str_repeat('a',32)."'")->fetchColumn();expect($status==='interrupted','Recovery must interrupt running job.'); + $db->exec("UPDATE jobs SET status='running' WHERE id='".str_repeat('a',32)."'"); + $reflection=new ReflectionClass(LabWorker::class);$method=$reflection->getMethod('run');$method->setAccessible(true); + $method->invoke($worker,['id'=>str_repeat('a',32),'owner'=>'local','name'=>'bad','kind'=>'model','spec'=>['invalid'=>true],'total_cycles'=>1]); + $row=$db->query("SELECT status,error FROM jobs WHERE id='".str_repeat('a',32)."'")->fetch();expect($row['status']==='failed'&&str_contains($row['error'],'validation'),'Invalid stored spec must fail before task launch.'); + putenv('EXOPLASIM_TEST_INHERIT=inherited-value');chdir($tmp);putenv('EXOPLASIM_CONFIG=config.json');$id=str_repeat('b',32);$spec=json_encode(['model'=>[],'run'=>[],'parameters'=>[]]);$s=$db->prepare("INSERT INTO jobs(id,owner,name,kind,spec,status,total_cycles,created_at,updated_at) VALUES(?,?,?,'model',?,'running',1,?,?)");$s->execute([$id,'local','test',$spec,$now,$now]); + $method->invoke($worker,['id'=>$id,'owner'=>'local','name'=>'test','kind'=>'model','spec'=>json_decode($spec,true),'total_cycles'=>1]); + $complete=$db->prepare('SELECT status,progress,completed_cycles FROM jobs WHERE id=?');$complete->execute([$id]);$result=$complete->fetch();$complete->closeCursor();expect($result['status']==='completed'&&(float)$result['progress']===100.0&&(int)$result['completed_cycles']===1,'Worker must drain final event and complete job.'); + $published=json_decode(file_get_contents($tmp.'/jobs/'.$id.'/artifacts/index.json'),true);expect(($published['latest']??null)===str_repeat('f',32),'PHP worker must publish the job results index.'); + expect(str_contains(file_get_contents($tmp.'/jobs/'.$id.'/worker.log'),'ENV_MARKER=inherited-value'),'Worker must preserve inherited process environment.'); + $planetId=str_repeat('e',32);$planetSpec=json_encode(['model'=>[],'run'=>[],'parameters'=>[],'surface'=>[]]);$p=$db->prepare("INSERT INTO jobs(id,owner,name,kind,spec,status,total_cycles,created_at,updated_at) VALUES(?,?,?,'planet',?,'running',1,?,?)");$p->execute([$planetId,'local','generated',$planetSpec,$now,$now]); + $method->invoke($worker,['id'=>$planetId,'owner'=>'local','name'=>'generated','kind'=>'planet','spec'=>json_decode($planetSpec,true),'total_cycles'=>1]); + $savedQuery=$db->query("SELECT experiment_id,status FROM jobs WHERE id='$planetId'");$saved=$savedQuery->fetch();$savedQuery->closeCursor();expect($saved['status']==='completed'&&preg_match('/^[a-f0-9]{32}$/',$saved['experiment_id']??''),'Planet job must save an experiment and link it to the job.'); + $planetIndex=json_decode(file_get_contents($tmp.'/jobs/'.$planetId.'/artifacts/index.json'),true);expect(($planetIndex['latest']??null)===str_repeat('e',32),'PHP worker must publish planet result index.');expect(count($planetIndex['datasets'])===1,'Planet event must publish its result index once.'); + $noOutputId=str_repeat('9',32);$s->execute([$noOutputId,'local','silent',$spec,$now,$now]); + file_put_contents($science.'/model.py',implode(PHP_EOL,["import argparse","p=argparse.ArgumentParser();p.add_argument('--config');p.add_argument('--job');p.parse_args()"])); + $method->invoke($worker,['id'=>$noOutputId,'owner'=>'local','name'=>'silent','kind'=>'model','spec'=>json_decode($spec,true),'total_cycles'=>1]);$silent=$db->query("SELECT status,error FROM jobs WHERE id='$noOutputId'")->fetch();expect($silent['status']==='failed'&&str_contains($silent['error'],'without publishing'),'Zero exit without results must fail.'); + $badKindId=str_repeat('8',32);$s->execute([$badKindId,'local','kind',$spec,$now,$now]);$method->invoke($worker,['id'=>$badKindId,'owner'=>'local','name'=>'kind','kind'=>'other','spec'=>json_decode($spec,true),'total_cycles'=>1]);expect($db->query("SELECT status FROM jobs WHERE id='$badKindId'")->fetchColumn()==='failed','Unsupported job kind must fail.'); + $badJsonId=str_repeat('7',32);$db->exec("INSERT INTO jobs(id,owner,name,spec,status,total_cycles,created_at,updated_at) VALUES('$badJsonId','local','json','{','queued',1,'$now','$now')");$badJson=$worker->claim();expect(($badJson['spec_error']??'')!=='','Malformed stored JSON must not terminate claim.');$method->invoke($worker,$badJson);expect($db->query("SELECT status FROM jobs WHERE id='$badJsonId'")->fetchColumn()==='failed','Malformed stored JSON must fail its job.'); + file_put_contents($tmp.'/large.log',str_repeat('x',1048576));$tailMethod=(new ReflectionClass(LabWorker::class))->getMethod('tail');$tailMethod->setAccessible(true);$tail=$tailMethod->invoke($worker,$tmp.'/large.log');expect(strlen($tail)===8192&&$tail===str_repeat('x',8192),'Log tail must read only its bounded suffix.'); + $sleepScript=<<<'PY' +import argparse,time +p=argparse.ArgumentParser();p.add_argument('--config');p.add_argument('--job');p.parse_args();time.sleep(20) +PY; +file_put_contents($science.'/model.py',$sleepScript); +$timeoutId=str_repeat('c',32);$s->execute([$timeoutId,'local','timeout',$spec,$now,$now]); +$property=(new ReflectionClass(LabWorker::class))->getProperty('config');$workerConfig=$property->getValue($worker);$workerConfig['job_timeout_seconds']=1;$property->setValue($worker,$workerConfig); +$begin=microtime(true);$method->invoke($worker,['id'=>$timeoutId,'owner'=>'local','name'=>'timeout','kind'=>'model','spec'=>json_decode($spec,true),'total_cycles'=>1]);$timedQuery=$db->query("SELECT status,error FROM jobs WHERE id='$timeoutId'");$timed=$timedQuery->fetch();$timedQuery->closeCursor();expect($timed['status']==='failed'&&str_contains($timed['error'],'time limit')&µtime(true)-$begin<8,'Worker must enforce timeout and stop the process group.'); + file_put_contents($science.'/model.py',$sleepScript);$cancelId=str_repeat('d',32);$s->execute([$cancelId,'local','cancel',$spec,$now,$now]); + $pid=pcntl_fork();if($pid===0){usleep(500000);$cancelDb=new PDO('sqlite:'.$tmp.'/data/lab.sqlite3');$cancelDb->exec("UPDATE jobs SET cancel_requested=1 WHERE id='$cancelId'");exit(0);} + $begin=microtime(true);$method->invoke($worker,['id'=>$cancelId,'owner'=>'local','name'=>'cancel','kind'=>'model','spec'=>json_decode($spec,true),'total_cycles'=>1]);pcntl_waitpid($pid,$childStatus);$cancelled=$db->query("SELECT status FROM jobs WHERE id='$cancelId'")->fetchColumn();expect($cancelled==='cancelled'&µtime(true)-$begin<8,'Worker must honor cancellation and stop the process group.'); + echo "PHP worker tests passed\n"; +} finally { + putenv('EXOPLASIM_CONFIG');chdir($originalCwd); + $it=new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmp,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST); + foreach($it as $file){$file->isDir()?rmdir($file->getPathname()):unlink($file->getPathname());} + rmdir($tmp); +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..f499afe --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,66 @@ +# Logs +logs +*.log +.fuse* +npm-debug.log* +yarn-debug.log* +yarn-error.log* +log/ +private/ +public/ +config/ + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# next.js build output +.next diff --git a/web/AGENTS.md b/web/AGENTS.md new file mode 100644 index 0000000..eebc85e --- /dev/null +++ b/web/AGENTS.md @@ -0,0 +1,15 @@ +# ExoPlaSim web application + +This directory is an application fork of Udo's maintained PHP web-app-starter. Project behavior belongs here. Do not put ExoPlaSim changes into the generic starter repository. + +Use the existing front controller, URL routing, component(), component_call(), safe(), and asafe(). Theme shell fragments belong in components/theme/shell.php. Do not add a second rendering framework or helper layer. + +Keep model execution and result extraction in the background worker. PHP requests read the queue and prepared artifacts. Do not execute a simulator, compiler, or Python process in a request. + +This is a single-user application. Do not add accounts, sign-in, or logout. This is a private dev toy. Do not add CSRF, sessions, network gates, TLS requirements, or application-specific nginx configuration. Keep SQLite, runs, and output outside the served source tree. Do not expose raw filesystem paths. + +Use config/web.json in the project root as the shared configuration source. Use the generated parameter catalog for scientific defaults. Read docs/work/web-ui.md for the current contracts and acceptance criteria. + +Keep UI copy limited to controls, data, units, and actionable status. Do not add demo records, marketing content, planning explanations, or inactive controls. + +Lint PHP, check JavaScript syntax, run the focused PHP/Python tests, and verify the real browser workflow before reporting completion. Use finite timeouts. Do not add hosted automation. diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..278d9f9 --- /dev/null +++ b/web/README.md @@ -0,0 +1,7 @@ +# ExoPlaSim workbench + +This directory contains the PHP application and browser assets. It uses the maintained PHP starter shell, escaping helpers, and component loader. + +See [the workbench guide](../docs/web-ui.md) for installation, input maps, APIs, and tests. Current open work is in [the goal tree](../docs/work/web-ui.md). + +Run this private dev app through ordinary PHP hosting. It needs no application-specific nginx configuration, TLS setup, accounts, sessions, or security gates. Application operations use ordinary query parameters, not URL paths. diff --git a/web/components/exoplasim/workbench.php b/web/components/exoplasim/workbench.php new file mode 100644 index 0000000..32f3c34 --- /dev/null +++ b/web/components/exoplasim/workbench.php @@ -0,0 +1,72 @@ + function($prop) { + $bootstrap = $prop['bootstrap'] ?? []; + ob_start(); ?> +
+
+ ExoPlaSim +
New experimentUnsaved
+
+ + + + + +
+
+
+ +
+
+
Select a completed run to inspect its spatial output.
+
+
RUN QUEUE
+
+
Run log
+ +
Select a run to view its log.
+
+
+ +
+ +
+
+ + + + + + + +
+
+
+
+
+ 'Shared theme shell fragments rendered through the component system.', + + 'option' => function($prop) { + $key = (string)($prop['key'] ?? ''); + $default = $prop['default'] ?? null; + $themes = cfg('theme/options'); + $currentThemeKey = (string)cfg('theme/key'); + $currentTheme = is_array($themes) && isset($themes[$currentThemeKey]) ? $themes[$currentThemeKey] : []; + if(is_array($currentTheme) && array_key_exists($key, $currentTheme)) return $currentTheme[$key]; + $direct = cfg('theme/'.$key); + return $direct !== null ? $direct : $default; + }, + + 'html_class' => function($prop) { + $classes = ['no-js']; + if(cfg('theme/mode') === 'dark') $classes[] = 'dark-theme'; + $extra = trim((string)($prop['extra_class'] ?? '')); + if($extra !== '') $classes[] = $extra; + return trim(implode(' ', $classes)); + }, + + 'head' => function($prop, $component) { + $themePath = (string)cfg('theme/path'); + $themePathCommon = 'themes/common/'; + $description = first($prop['description'] ?? false, $component['option'](['key' => 'meta_description', 'default' => '']), 'Web App Starter theme'); + $themeColor = first($prop['theme_color'] ?? false, $component['option'](['key' => 'theme_color', 'default' => '']), '#0f172a'); + include_css($themePath.'css/style.css'); + include_js('js/u-query.js'); + include_js('js/morphdom.js'); + include_js('js/site.js'); + foreach((array)($prop['extra_css'] ?? []) as $extraCss) include_css($extraCss); + foreach((array)($prop['extra_js'] ?? []) as $extraJs) include_js($extraJs); + ob_start(); ?> + + <?= first(URL::$route['page-title'] ?? false, cfg('site/default_page_title')).' | '.cfg('site/name') ?> + + + + function($prop) { + $menuKey = (string)($prop['menu_key'] ?? ''); + $menuItem = is_array($prop['menu_item'] ?? null) ? $prop['menu_item'] : []; + if(!empty($menuItem['external'])) return '/'.ltrim($menuKey, '/'); + return URL::Link($menuKey); + }, + + 'standard_nav' => function($prop, $component) { + $navClass = trim((string)($prop['nav_class'] ?? '')); + ob_start(); ?> + > + + + function($prop, $component) { + if(!empty($prop['embed_mode'])) return ''; + $footerText = first($prop['text'] ?? false, $component['option'](['key' => 'footer_text', 'default' => false]), cfg('site/name')); + $innerClass = trim((string)($prop['inner_class'] ?? '')); + ob_start(); ?> +
+ +

+ +

+ +
+ 'index', 'l-path' => '']); +if (isset($_GET['api'])) { + require 'views/api/index.php'; + exit; +} +Profiler::log('main content: start', 1); +ob_start(); +require 'views/index.php'; +URL::$fragments['main'] = ob_get_clean(); +Profiler::log('main content: end', -1); +require cfg('theme/path') . '/page.html.php'; +Log::audit('page:index', ''); diff --git a/web/js/composite.js b/web/js/composite.js new file mode 100644 index 0000000..813900c --- /dev/null +++ b/web/js/composite.js @@ -0,0 +1,41 @@ +(function (root) { + 'use strict'; + + const CHANNELS = [ + { channel: 'red', name: 'ts', label: 'Surface temperature', unit: 'K', factor: 1 }, + { channel: 'green', name: 'pr', label: 'Precipitation', unit: 'mm/day', factor: 86400000 }, + { channel: 'blue', name: 'sg', label: 'Elevation', unit: 'm', factor: null } + ]; + + function create(index, gravity) { + if (!index || !Array.isArray(index.fields) || !Number.isFinite(gravity) || gravity <= 0) return null; + const fields = CHANNELS.map(channel => { + const field = index.fields.find(item => item.name === channel.name); + if (!field || !Array.isArray(field.shape) || field.shape.length !== 4 || field.shape[1] !== 1 || !field.shape[0] || field.shape[2] < 1 || field.shape[3] < 1 || !Number.isFinite(field.min) || !Number.isFinite(field.max)) return null; + const expectedUnit = channel.name === 'ts' ? 'K' : channel.name === 'pr' ? 'm s-1' : 'm2 s-2'; + if (field.unit !== expectedUnit) return null; + return Object.assign({}, channel, { + factor: channel.factor === null ? 1 / gravity : channel.factor, + min: field.min * (channel.factor === null ? 1 / gravity : channel.factor), + max: field.max * (channel.factor === null ? 1 / gravity : channel.factor), + shape: field.shape.slice() + }); + }); + if (fields.some(field => !field || !Number.isFinite(field.min) || !Number.isFinite(field.max) || field.max < field.min)) return null; + const [reference] = fields; + if (fields.some(field => field.shape[2] !== reference.shape[2] || field.shape[3] !== reference.shape[3])) return null; + const timeCount = Math.max(...fields.map(field => field.shape[0])); + if (fields.some(field => field.shape[0] !== 1 && field.shape[0] !== timeCount)) return null; + return { channels: fields, timeCount }; + } + + function normalise(value, channel) { + if (!channel || !Number.isFinite(value)) return null; + const converted = value * channel.factor; + if (!Number.isFinite(converted)) return null; + if (channel.max === channel.min) return converted === 0 ? 0 : 0.5; + return Math.max(0, Math.min(1, (converted - channel.min) / (channel.max - channel.min))); + } + + root.ExoComposite = { create, normalise }; +})(window); diff --git a/web/js/map-editor.js b/web/js/map-editor.js new file mode 100644 index 0000000..e29eac7 --- /dev/null +++ b/web/js/map-editor.js @@ -0,0 +1,161 @@ +(function () { + 'use strict'; + const radians = Math.PI / 180; + const clamp = (value, range) => Math.max(range.min, Math.min(range.max, value)); + function paint(values, lat, lon, center, radius, strength, tool, range) { + const source = tool === 'smooth' ? values.slice() : values; + const phi = center.lat * radians, limit = Math.cos(radius * radians), cols = lon.length, sine = Math.sin(phi), cosine = Math.cos(phi); + let changed = false; + for (let y = 0; y < lat.length; y++) { + const latitude = lat[y] * radians, offset = sine * Math.sin(latitude), scale = cosine * Math.cos(latitude); + for (let x = 0; x < cols; x++) { + const dot = offset + scale * Math.cos((lon[x] - center.lon) * radians); + if (dot < limit) continue; + const i = y * cols + x; + let value = values[i]; + if (tool === 'set') value = strength; + else if (tool === 'smooth') { + const above = Math.max(0, y - 1), below = Math.min(lat.length - 1, y + 1); + value = (source[above * cols + x] + source[below * cols + x] + source[y * cols + (x + cols - 1) % cols] + source[y * cols + (x + 1) % cols] + source[i]) / 5; + } else { + const weight = (dot - limit) / (1 - limit); + value += (tool === 'lower' ? -1 : 1) * Math.abs(strength) * weight; + } + value = Math.fround(clamp(value, range)); + if (value !== values[i]) { values[i] = value; changed = true; } + } + } + return changed; + } + window.ExoMapBrush = paint; + window.ExoMapEditor = function (root, options) { + let values = null, original = null, grid = null, rule = null, dirty = false, busy = false, saving = false, token = 0; + let undo = [], redo = [], stroke = null, raf = 0, pending = null, last = null, range = {min:0,max:1}; + const raster=document.createElement('canvas'),rasterContext=raster.getContext('2d'); + let rasterImage=null,rasterRows=[]; + root.innerHTML = `
`; + const $ = selector => root.querySelector(selector), canvas = $('.map-editor-canvas'), ctx = canvas.getContext('2d'); + const field = $('[data-map-field]'), source = $('[data-map-source]'), tool = $('[data-map-tool]'); + const status = text => { $('[data-map-status]').textContent = text; }; + const base=document.createElement('canvas');base.width=canvas.width;base.height=canvas.height;const baseContext=base.getContext('2d'); + const listeners = new AbortController(); + const on = (node, event, callback) => node.addEventListener(event, callback, {signal:listeners.signal}); + let cursor = {lat:0,lon:0}; + function controls() { + $('[data-map-undo]').disabled = busy || !undo.length; + $('[data-map-redo]').disabled = busy || !redo.length; + $('[data-map-save]').disabled = busy || !values || !dirty; + root.querySelectorAll('input,select,[data-map-ocean],[data-map-land]').forEach(control=>control.disabled=busy); + options.onDirty(dirty); + } + function nearest(coords, value, periodic=false) { + let best = 0, distance = Infinity; + coords.forEach((coordinate, i) => { let d = Math.abs(coordinate - value); if(periodic) { d %= 360; d = Math.min(d,360-d); } if(d < distance) { distance=d;best=i; } }); + return best; + } + function inspect() { + if(!values)return; + const y=nearest(grid.lat,cursor.lat),x=nearest(grid.lon,cursor.lon,true); + $('[data-map-position]').textContent=`${Number(grid.lat[y]).toFixed(2)}° latitude · ${Number(grid.lon[x]).toFixed(2)}° longitude · ${Number(values[y*grid.lon.length+x]).toPrecision(6)}${rule.unit==='1'?'':' '+rule.unit}`; + } + function drawCursor(){ + if(!values)return;ctx.drawImage(base,0,0); + const radius=Number($('[data-map-radius]').value)*radians,phi=cursor.lat*radians,start=grid.lon[0]-(grid.lon.length>1?(grid.lon[1]-grid.lon[0])/2:180); + ctx.strokeStyle='#fff';ctx.lineWidth=1.5;ctx.beginPath();let previous=null; + for(let i=0;i<=64;i++){const angle=i*Math.PI/32,latitude=Math.asin(Math.sin(phi)*Math.cos(radius)+Math.cos(phi)*Math.sin(radius)*Math.cos(angle));const longitude=cursor.lon+Math.atan2(Math.sin(angle)*Math.sin(radius)*Math.cos(phi),Math.cos(radius)-Math.sin(phi)*Math.sin(latitude))/radians; + const x=(((longitude-start)%360+360)%360)/360*canvas.width,y=(90-latitude/radians)/180*canvas.height; + if(previous===null||Math.abs(x-previous)>canvas.width/2)ctx.moveTo(x,y);else ctx.lineTo(x,y);previous=x; + }ctx.stroke(); + } + function draw() { + if(!values)return; + const rows=grid.lat.length,cols=grid.lon.length; + range={min:Infinity,max:-Infinity};for(const value of values){range.min=Math.min(range.min,value);range.max=Math.max(range.max,value);} + if(!rasterImage){raster.width=cols;raster.height=rows*4;rasterImage=rasterContext.createImageData(cols,raster.height);rasterRows=Array.from({length:raster.height},(_,y)=>nearest(grid.lat,90-(y+.5)*180/raster.height));} + const image=rasterImage; + for(let y=0;y=.5?208:12):35+210*t; + image.data[i+1]=rule.categorical?(value>=.5?204:41):40+205*t; + image.data[i+2]=rule.categorical?(value>=.5?165:63):48+197*t; + image.data[i+3]=255; + } + } + rasterContext.putImageData(image,0,0);ctx.imageSmoothingEnabled=false;ctx.drawImage(raster,0,0,canvas.width,canvas.height);baseContext.drawImage(canvas,0,0);drawCursor(); + $('[data-map-min]').textContent=`${range.min.toPrecision(6)} ${rule.unit}`; + $('[data-map-max]').textContent=`${range.max.toPrecision(6)} ${rule.unit}`; + inspect();controls(); + } + function keepHistory(previous) { undo.push(previous);if(undo.length>32)undo.shift();redo=[];dirty=true; } + function dab(point) { + const radius=Number($('[data-map-radius]').value),strength=Number($('[data-map-value]').value); + if(!Number.isFinite(radius)||radius<1||radius>90||!Number.isFinite(strength)){status('Enter a finite value and a radius from 1° to 90°.');return false;} + if(rule.categorical&&strength!==rule.min&&strength!==rule.max){status('Choose Ocean or Land.');return false;} + if(tool.value==='set'&&(strengthrule.max)){status(`Value must be from ${rule.min} to ${rule.max} ${rule.unit}.`);return false;} + return paint(values,grid.lat,grid.lon,point,radius,strength,tool.value,rule); + } + function flush() { + raf=0;if(!pending||busy||!values)return; + const point=pending;pending=null; + let changed=false; + const radius=Number($('[data-map-radius]').value),distance=last?Math.hypot(point.lat-last.lat,((point.lon-last.lon+540)%360)-180):0; + const steps=Math.min(8,Math.max(1,Math.ceil(distance/Math.max(1,radius/2)))); + for(let step=1;step<=steps;step++){const t=step/steps,delta=last?((point.lon-last.lon+540)%360)-180:0;changed=dab(last?{lat:last.lat+(point.lat-last.lat)*t,lon:last.lon+delta*t}:point)||changed;} + if(changed){if(!stroke.changed){keepHistory(stroke.before);stroke.changed=true;}draw();status('Unsaved map changes.');} + last=point;cursor=point;inspect(); + } + function position(event) { + const box=canvas.getBoundingClientRect(),step=grid.lon.length>1?grid.lon[1]-grid.lon[0]:360; + return {lat:Math.max(-90,Math.min(90,90-(event.clientY-box.top)/box.height*180)),lon:grid.lon[0]-step/2+(event.clientX-box.left)/box.width*360}; + } + on(canvas,'pointerdown',event=>{if(busy||!values||event.button!==0)return;event.preventDefault();canvas.focus({preventScroll:true});canvas.setPointerCapture(event.pointerId);stroke={before:values.slice(),changed:false};last=null;pending=position(event);flush();}); + on(canvas,'pointermove',event=>{if(!values)return;cursor=position(event);inspect();drawCursor();if(!stroke)return;pending=cursor;if(!raf)raf=requestAnimationFrame(flush);}); + const finish=()=>{if(raf){cancelAnimationFrame(raf);raf=0;}if(stroke&&pending)flush();stroke=null;last=null;pending=null;}; + on(canvas,'pointerup',finish);on(canvas,'pointercancel',finish);on(canvas,'lostpointercapture',finish); + on(canvas,'keydown',event=>{if(!values||busy)return;const y=nearest(grid.lat,cursor.lat),x=nearest(grid.lon,cursor.lon,true);let row=y,col=x; + if(event.key==='ArrowUp')row=Math.min(grid.lat.length-1,Math.max(0,y+(grid.lat[0]{$('[data-map-value]').value=rule.min;}); + on($('[data-map-land]'),'click',()=>{$('[data-map-value]').value=rule.max;}); + on($('[data-map-radius]'),'input',drawCursor); + on($('[data-map-undo]'),'click',()=>{if(!undo.length||busy)return;redo.push(values.slice());values=undo.pop();dirty=values.some((value,i)=>value!==original[i]);draw();status(dirty?'Unsaved map changes.':'');}); + on($('[data-map-redo]'),'click',()=>{if(!redo.length||busy)return;undo.push(values.slice());values=redo.pop();dirty=values.some((value,i)=>value!==original[i]);draw();status(dirty?'Unsaved map changes.':'');}); + async function load() { + const current=++token;busy=true;controls();ctx.clearRect(0,0,canvas.width,canvas.height);for(const selector of ['[data-map-min]','[data-map-max]','[data-map-position]'])$(selector).textContent='';status('Loading map…'); + try { const loaded=await options.load(field.value,source.value);if(current!==token)return; + values=loaded.values.slice();original=values.slice();grid=loaded.grid;rule=options.fields[field.value];rasterImage=null; + if(values.length!==grid.lat.length*grid.lon.length||Array.from(values).some(v=>!Number.isFinite(v)))throw Error('Map data does not match its grid.'); + undo=[];redo=[];dirty=false;cursor={lat:0,lon:0};$('[data-map-name]').value=loaded.name||rule.label; + $('[data-map-unit]').textContent=rule.unit==='1'?'Value':`Value (${rule.unit})`; + $('[data-map-ocean]').hidden=$('[data-map-land]').hidden=!rule.categorical;$('[data-map-value]').value=rule.categorical?rule.max:(rule.brush_step??(rule.max-rule.min)/100); + for(const option of tool.options)option.disabled=Boolean(rule.categorical)&&option.value!=='set';tool.value=rule.categorical?'set':'raise'; + draw();status(''); + }catch(error){if(current===token){values=null;ctx.clearRect(0,0,canvas.width,canvas.height);status(error.message);}} + finally{if(current===token){busy=false;controls();}} + } + async function sources() { + const entries=await options.sources(field.value);source.replaceChildren(); + for(const entry of entries){const option=document.createElement('option');option.value=entry.id;option.textContent=entry.name;source.append(option);} + await load(); + } + function canDiscard(){return !dirty||confirm('Discard unsaved map changes?');} + let previousField='',previousSource=''; + on(field,'change',async()=>{if(!canDiscard()){field.value=previousField;return;}previousField=field.value;await sources();previousSource=source.value;}); + on(source,'change',async()=>{if(!canDiscard()){source.value=previousSource;return;}previousSource=source.value;await load();}); + on($('[data-map-save]'),'click',async()=>{if(busy||!values)return;finish();const name=$('[data-map-name]').value.trim();if(!name){status('Enter a map name.');return;}busy=true;saving=true;controls();status('Saving map…'); + try{const saved=await options.save(field.value,name,values.slice());dirty=false;original=values.slice();undo=[];redo=[];status('Map saved. Save the experiment to use it.');options.onSaved(field.value,saved);} + catch(error){status(error.message);}finally{busy=false;saving=false;controls();} + }); + for(const [name,metadata]of Object.entries(options.fields)){const option=document.createElement('option');option.value=name;option.textContent=metadata.label+(metadata.unit==='1'?'':` (${metadata.unit})`);field.append(option);} + this.start=async()=>{previousField=field.value;await sources();previousSource=source.value;}; + this.canClose=()=>!saving&&canDiscard(); + this.destroy=()=>{token++;listeners.abort();if(raf)cancelAnimationFrame(raf);root.replaceChildren();}; + }; +})(); diff --git a/web/js/morphdom.js b/web/js/morphdom.js new file mode 100644 index 0000000..6746f0e --- /dev/null +++ b/web/js/morphdom.js @@ -0,0 +1 @@ +(function(global,factory){typeof exports==="object"&&typeof module!=="undefined"?module.exports=factory():typeof define==="function"&&define.amd?define(factory):(global=global||self,global.morphdom=factory())})(this,function(){"use strict";var DOCUMENT_FRAGMENT_NODE=11;function morphAttrs(fromNode,toNode){var toNodeAttrs=toNode.attributes;var attr;var attrName;var attrNamespaceURI;var attrValue;var fromValue;if(toNode.nodeType===DOCUMENT_FRAGMENT_NODE||fromNode.nodeType===DOCUMENT_FRAGMENT_NODE){return}for(var i=toNodeAttrs.length-1;i>=0;i--){attr=toNodeAttrs[i];attrName=attr.name;attrNamespaceURI=attr.namespaceURI;attrValue=attr.value;if(attrNamespaceURI){attrName=attr.localName||attrName;fromValue=fromNode.getAttributeNS(attrNamespaceURI,attrName);if(fromValue!==attrValue){if(attr.prefix==="xmlns"){attrName=attr.name}fromNode.setAttributeNS(attrNamespaceURI,attrName,attrValue)}}else{fromValue=fromNode.getAttribute(attrName);if(fromValue!==attrValue){fromNode.setAttribute(attrName,attrValue)}}}var fromNodeAttrs=fromNode.attributes;for(var d=fromNodeAttrs.length-1;d>=0;d--){attr=fromNodeAttrs[d];attrName=attr.name;attrNamespaceURI=attr.namespaceURI;if(attrNamespaceURI){attrName=attr.localName||attrName;if(!toNode.hasAttributeNS(attrNamespaceURI,attrName)){fromNode.removeAttributeNS(attrNamespaceURI,attrName)}}else{if(!toNode.hasAttribute(attrName)){fromNode.removeAttribute(attrName)}}}}var range;var NS_XHTML="http://www.w3.org/1999/xhtml";var doc=typeof document==="undefined"?undefined:document;var HAS_TEMPLATE_SUPPORT=!!doc&&"content"in doc.createElement("template");var HAS_RANGE_SUPPORT=!!doc&&doc.createRange&&"createContextualFragment"in doc.createRange();function createFragmentFromTemplate(str){var template=doc.createElement("template");template.innerHTML=str;return template.content.childNodes[0]}function createFragmentFromRange(str){if(!range){range=doc.createRange();range.selectNode(doc.body)}var fragment=range.createContextualFragment(str);return fragment.childNodes[0]}function createFragmentFromWrap(str){var fragment=doc.createElement("body");fragment.innerHTML=str;return fragment.childNodes[0]}function toElement(str){str=str.trim();if(HAS_TEMPLATE_SUPPORT){return createFragmentFromTemplate(str)}else if(HAS_RANGE_SUPPORT){return createFragmentFromRange(str)}return createFragmentFromWrap(str)}function compareNodeNames(fromEl,toEl){var fromNodeName=fromEl.nodeName;var toNodeName=toEl.nodeName;var fromCodeStart,toCodeStart;if(fromNodeName===toNodeName){return true}fromCodeStart=fromNodeName.charCodeAt(0);toCodeStart=toNodeName.charCodeAt(0);if(fromCodeStart<=90&&toCodeStart>=97){return fromNodeName===toNodeName.toUpperCase()}else if(toCodeStart<=90&&fromCodeStart>=97){return toNodeName===fromNodeName.toUpperCase()}else{return false}}function createElementNS(name,namespaceURI){return!namespaceURI||namespaceURI===NS_XHTML?doc.createElement(name):doc.createElementNS(namespaceURI,name)}function moveChildren(fromEl,toEl){var curChild=fromEl.firstChild;while(curChild){var nextChild=curChild.nextSibling;toEl.appendChild(curChild);curChild=nextChild}return toEl}function syncBooleanAttrProp(fromEl,toEl,name){if(fromEl[name]!==toEl[name]){fromEl[name]=toEl[name];if(fromEl[name]){fromEl.setAttribute(name,"")}else{fromEl.removeAttribute(name)}}}var specialElHandlers={OPTION:function(fromEl,toEl){var parentNode=fromEl.parentNode;if(parentNode){var parentName=parentNode.nodeName.toUpperCase();if(parentName==="OPTGROUP"){parentNode=parentNode.parentNode;parentName=parentNode&&parentNode.nodeName.toUpperCase()}if(parentName==="SELECT"&&!parentNode.hasAttribute("multiple")){if(fromEl.hasAttribute("selected")&&!toEl.selected){fromEl.setAttribute("selected","selected");fromEl.removeAttribute("selected")}parentNode.selectedIndex=-1}}syncBooleanAttrProp(fromEl,toEl,"selected")},INPUT:function(fromEl,toEl){syncBooleanAttrProp(fromEl,toEl,"checked");syncBooleanAttrProp(fromEl,toEl,"disabled");if(fromEl.value!==toEl.value){fromEl.value=toEl.value}if(!toEl.hasAttribute("value")){fromEl.removeAttribute("value")}},TEXTAREA:function(fromEl,toEl){var newValue=toEl.value;if(fromEl.value!==newValue){fromEl.value=newValue}var firstChild=fromEl.firstChild;if(firstChild){var oldValue=firstChild.nodeValue;if(oldValue==newValue||!newValue&&oldValue==fromEl.placeholder){return}firstChild.nodeValue=newValue}},SELECT:function(fromEl,toEl){if(!toEl.hasAttribute("multiple")){var selectedIndex=-1;var i=0;var curChild=fromEl.firstChild;var optgroup;var nodeName;while(curChild){nodeName=curChild.nodeName&&curChild.nodeName.toUpperCase();if(nodeName==="OPTGROUP"){optgroup=curChild;curChild=optgroup.firstChild}else{if(nodeName==="OPTION"){if(curChild.hasAttribute("selected")){selectedIndex=i;break}i++}curChild=curChild.nextSibling;if(!curChild&&optgroup){curChild=optgroup.nextSibling;optgroup=null}}}fromEl.selectedIndex=selectedIndex}}};var ELEMENT_NODE=1;var DOCUMENT_FRAGMENT_NODE$1=11;var TEXT_NODE=3;var COMMENT_NODE=8;function noop(){}function defaultGetNodeKey(node){if(node){return node.getAttribute&&node.getAttribute("id")||node.id}}function morphdomFactory(morphAttrs){return function morphdom(fromNode,toNode,options){if(!options){options={}}if(typeof toNode==="string"){if(fromNode.nodeName==="#document"||fromNode.nodeName==="HTML"||fromNode.nodeName==="BODY"){var toNodeHtml=toNode;toNode=doc.createElement("html");toNode.innerHTML=toNodeHtml}else{toNode=toElement(toNode)}}else if(toNode.nodeType===DOCUMENT_FRAGMENT_NODE$1){toNode=toNode.firstElementChild}var getNodeKey=options.getNodeKey||defaultGetNodeKey;var onBeforeNodeAdded=options.onBeforeNodeAdded||noop;var onNodeAdded=options.onNodeAdded||noop;var onBeforeElUpdated=options.onBeforeElUpdated||noop;var onElUpdated=options.onElUpdated||noop;var onBeforeNodeDiscarded=options.onBeforeNodeDiscarded||noop;var onNodeDiscarded=options.onNodeDiscarded||noop;var onBeforeElChildrenUpdated=options.onBeforeElChildrenUpdated||noop;var skipFromChildren=options.skipFromChildren||noop;var addChild=options.addChild||function(parent,child){return parent.appendChild(child)};var childrenOnly=options.childrenOnly===true;var fromNodesLookup=Object.create(null);var keyedRemovalList=[];function addKeyedRemoval(key){keyedRemovalList.push(key)}function walkDiscardedChildNodes(node,skipKeyedNodes){if(node.nodeType===ELEMENT_NODE){var curChild=node.firstChild;while(curChild){var key=undefined;if(skipKeyedNodes&&(key=getNodeKey(curChild))){addKeyedRemoval(key)}else{onNodeDiscarded(curChild);if(curChild.firstChild){walkDiscardedChildNodes(curChild,skipKeyedNodes)}}curChild=curChild.nextSibling}}}function removeNode(node,parentNode,skipKeyedNodes){if(onBeforeNodeDiscarded(node)===false){return}if(parentNode){parentNode.removeChild(node)}onNodeDiscarded(node);walkDiscardedChildNodes(node,skipKeyedNodes)}function indexTree(node){if(node.nodeType===ELEMENT_NODE||node.nodeType===DOCUMENT_FRAGMENT_NODE$1){var curChild=node.firstChild;while(curChild){var key=getNodeKey(curChild);if(key){fromNodesLookup[key]=curChild}indexTree(curChild);curChild=curChild.nextSibling}}}indexTree(fromNode);function handleNodeAdded(el){onNodeAdded(el);var curChild=el.firstChild;while(curChild){var nextSibling=curChild.nextSibling;var key=getNodeKey(curChild);if(key){var unmatchedFromEl=fromNodesLookup[key];if(unmatchedFromEl&&compareNodeNames(curChild,unmatchedFromEl)){curChild.parentNode.replaceChild(unmatchedFromEl,curChild);morphEl(unmatchedFromEl,curChild)}else{handleNodeAdded(curChild)}}else{handleNodeAdded(curChild)}curChild=nextSibling}}function cleanupFromEl(fromEl,curFromNodeChild,curFromNodeKey){while(curFromNodeChild){var fromNextSibling=curFromNodeChild.nextSibling;if(curFromNodeKey=getNodeKey(curFromNodeChild)){addKeyedRemoval(curFromNodeKey)}else{removeNode(curFromNodeChild,fromEl,true)}curFromNodeChild=fromNextSibling}}function morphEl(fromEl,toEl,childrenOnly){var toElKey=getNodeKey(toEl);if(toElKey){delete fromNodesLookup[toElKey]}if(!childrenOnly){var beforeUpdateResult=onBeforeElUpdated(fromEl,toEl);if(beforeUpdateResult===false){return}else if(beforeUpdateResult instanceof HTMLElement){fromEl=beforeUpdateResult;indexTree(fromEl)}morphAttrs(fromEl,toEl);onElUpdated(fromEl);if(onBeforeElChildrenUpdated(fromEl,toEl)===false){return}}if(fromEl.nodeName!=="TEXTAREA"){morphChildren(fromEl,toEl)}else{specialElHandlers.TEXTAREA(fromEl,toEl)}}function morphChildren(fromEl,toEl){var skipFrom=skipFromChildren(fromEl,toEl);var curToNodeChild=toEl.firstChild;var curFromNodeChild=fromEl.firstChild;var curToNodeKey;var curFromNodeKey;var fromNextSibling;var toNextSibling;var matchingFromEl;outer:while(curToNodeChild){toNextSibling=curToNodeChild.nextSibling;curToNodeKey=getNodeKey(curToNodeChild);while(!skipFrom&&curFromNodeChild){fromNextSibling=curFromNodeChild.nextSibling;if(curToNodeChild.isSameNode&&curToNodeChild.isSameNode(curFromNodeChild)){curToNodeChild=toNextSibling;curFromNodeChild=fromNextSibling;continue outer}curFromNodeKey=getNodeKey(curFromNodeChild);var curFromNodeType=curFromNodeChild.nodeType;var isCompatible=undefined;if(curFromNodeType===curToNodeChild.nodeType){if(curFromNodeType===ELEMENT_NODE){if(curToNodeKey){if(curToNodeKey!==curFromNodeKey){if(matchingFromEl=fromNodesLookup[curToNodeKey]){if(fromNextSibling===matchingFromEl){isCompatible=false}else{fromEl.insertBefore(matchingFromEl,curFromNodeChild);if(curFromNodeKey){addKeyedRemoval(curFromNodeKey)}else{removeNode(curFromNodeChild,fromEl,true)}curFromNodeChild=matchingFromEl;curFromNodeKey=getNodeKey(curFromNodeChild)}}else{isCompatible=false}}}else if(curFromNodeKey){isCompatible=false}isCompatible=isCompatible!==false&&compareNodeNames(curFromNodeChild,curToNodeChild);if(isCompatible){morphEl(curFromNodeChild,curToNodeChild)}}else if(curFromNodeType===TEXT_NODE||curFromNodeType==COMMENT_NODE){isCompatible=true;if(curFromNodeChild.nodeValue!==curToNodeChild.nodeValue){curFromNodeChild.nodeValue=curToNodeChild.nodeValue}}}if(isCompatible){curToNodeChild=toNextSibling;curFromNodeChild=fromNextSibling;continue outer}if(curFromNodeKey){addKeyedRemoval(curFromNodeKey)}else{removeNode(curFromNodeChild,fromEl,true)}curFromNodeChild=fromNextSibling}if(curToNodeKey&&(matchingFromEl=fromNodesLookup[curToNodeKey])&&compareNodeNames(matchingFromEl,curToNodeChild)){if(!skipFrom){addChild(fromEl,matchingFromEl)}morphEl(matchingFromEl,curToNodeChild)}else{var onBeforeNodeAddedResult=onBeforeNodeAdded(curToNodeChild);if(onBeforeNodeAddedResult!==false){if(onBeforeNodeAddedResult){curToNodeChild=onBeforeNodeAddedResult}if(curToNodeChild.actualize){curToNodeChild=curToNodeChild.actualize(fromEl.ownerDocument||doc)}addChild(fromEl,curToNodeChild);handleNodeAdded(curToNodeChild)}}curToNodeChild=toNextSibling;curFromNodeChild=fromNextSibling}cleanupFromEl(fromEl,curFromNodeChild,curFromNodeKey);var specialElHandler=specialElHandlers[fromEl.nodeName];if(specialElHandler){specialElHandler(fromEl,toEl)}}var morphedNode=fromNode;var morphedNodeType=morphedNode.nodeType;var toNodeType=toNode.nodeType;if(!childrenOnly){if(morphedNodeType===ELEMENT_NODE){if(toNodeType===ELEMENT_NODE){if(!compareNodeNames(fromNode,toNode)){onNodeDiscarded(fromNode);morphedNode=moveChildren(fromNode,createElementNS(toNode.nodeName,toNode.namespaceURI))}}else{morphedNode=toNode}}else if(morphedNodeType===TEXT_NODE||morphedNodeType===COMMENT_NODE){if(toNodeType===morphedNodeType){if(morphedNode.nodeValue!==toNode.nodeValue){morphedNode.nodeValue=toNode.nodeValue}return morphedNode}else{morphedNode=toNode}}}if(morphedNode===toNode){onNodeDiscarded(fromNode)}else{if(toNode.isSameNode&&toNode.isSameNode(morphedNode)){return}morphEl(morphedNode,toNode,childrenOnly);if(keyedRemovalList){for(var i=0,len=keyedRemovalList.length;i { + anchor.addEventListener('click', function (e) { + e.preventDefault(); + const target = document.querySelector(this.getAttribute('href')); + if (target) { + target.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); + } + }); + }); + }, + + enablePageTransitions: function() { + $(document.body).append(``); + + document.addEventListener('click', e => { + const link = e.target.closest('a[href]'); + if (!link) return; + + e.preventDefault(); + + document.startViewTransition(() => { + window.location.href = link.href; + }); + }); + }, + + init: function() { + //UI.enablePageTransitions(); + UI.smoothScrollToNamedAnchors(); + document.body.classList.add('loaded'); + }, + +} + +$.ready(UI.init); diff --git a/web/js/u-macrobars.md b/web/js/u-macrobars.md new file mode 100644 index 0000000..191aedb --- /dev/null +++ b/web/js/u-macrobars.md @@ -0,0 +1,137 @@ +# macrobars.js + +A lightweight JavaScript templating engine with PHP-style syntax and Handlebars-inspired features. + +## Basic Usage + +```javascript +const template = Macrobars.compile('

{{title}}

{{content}}

'); +const html = template({title: 'Hello', content: 'World'}); +``` + +## Field Output + +```javascript +{{field}} // Safe HTML output (escaped) +{{{field}}} // Unsafe HTML output (raw) +{{field or "default"}} // With default value +{{:variable}} // Direct variable (no data. prefix) +``` + +## Numbers + +```javascript +{{%number}} // Formatted number (2 decimals) +{{~number}} // Rounded number (1k, 1M format) +``` + +## Control Structures + +### Conditionals +```javascript +{{#if condition}} + Content when true +{{#else}} + Content when false +{{/if}} +``` + +### Equality Blocks +```javascript +{{#eq value1 value2}} + Content when equal +{{/eq}} + +{{eq value1 value2}} // Inline: outputs "true" or "" +``` + +### Loops +```javascript +{{#each items}} + {{name}} - {{index}} +{{/each}} + +{{#each items as item}} + {{item.name}} +{{/each}} +``` + +### Property Lookup +```javascript +{{#lookup object key}} + {{value}} exists +{{/lookup}} + +{{lookup object key}} // Inline: outputs value or "" +``` + +## Code Blocks + +```javascript + + + + // Deferred JavaScript execution + + + // PHP-style output + // PHP-style code +``` + +## Event Binding + +```javascript + +``` + +Events are automatically bound when using `renderTo()`. + +## Components + +```javascript +{{#component myComponent}} // Render registered component +``` + +## Compilation Options + +```javascript +const template = Macrobars.compile(templateString, { + decimals: 2, // Number formatting precision + strict: true, // Use strict mode + components: {...} // Reusable components +}); +``` + +## DOM Integration + +```javascript +template.renderTo('#container', data); // Render to element +template.renderTo(document.getElementById('x'), data); +``` + +## Utility Functions + +```javascript +Macrobars.safe_out(text, default) // HTML escape +Macrobars.num_out(number, decimals) // Format number +Macrobars.num_out_round(number) // Round with k/M suffix +``` + +## Template Debugging + +Access compiled template information: +```javascript +template.tokens // Parsed tokens +template.gensource // Generated JavaScript +template.event_bindings // Event binding data +``` + +## Default Values + +```javascript +{{#default "N/A"}} // Set default for empty fields +{{field}} // Will show "N/A" if empty +``` + diff --git a/web/js/u-query.js b/web/js/u-query.js new file mode 100644 index 0000000..907e8ba --- /dev/null +++ b/web/js/u-query.js @@ -0,0 +1,589 @@ +(function (root, factory) { + // this is the stupid UMD pattern, but eh we lost that battle a long time ago + if (typeof exports === 'object' && typeof module !== 'undefined') { + var exports_obj = factory(); + module.exports = exports_obj.$; + // Also export individual components + for (var key in exports_obj) { + if (key !== '$' && exports_obj.hasOwnProperty(key)) { + exports[key] = exports_obj[key]; + } + } + } else if (typeof define === 'function' && define.amd) { + define([], factory); + } else { + var exports_obj = factory(); + root.$ = exports_obj.$; + for (var key in exports_obj) { + if (key !== '$' && exports_obj.hasOwnProperty(key)) { + root[key] = exports_obj[key]; + } + } + } +}(typeof self !== 'undefined' ? self : this, function () { + +// a collection of DOM utility functions that I thought were cool in jQuery + +class EventEmitter { + constructor() { + /** Map> */ + this._topics = new Map(); + } + + /** + * Subscribe to `topic`. If `slot_key` is provided (truthy), + * it dedupes by that key; otherwise a unique Symbol is used. + * Returns an unsubscribe fn. + */ + on(topic, handler, slot_key = null) { + let map = this._topics.get(topic); + if (!map) { + map = new Map(); + this._topics.set(topic, map); + } + // use the provided slot_key or a fresh Symbol() + const key = slot_key != null ? slot_key : Symbol(); + map.set(key, handler); + return () => this.off(topic, key); + } + + /** + * Unsubscribe by handler function or by slot_key. + */ + off(topic, handlerOrSlotKey) { + const map = this._topics.get(topic); + if (!map) return; + + // if it matches a slotKey directly, remove it + if (map.has(handlerOrSlotKey)) { + map.delete(handlerOrSlotKey); + } else { + // otherwise assume it's a function: remove all matching fns + for (const [key, fn] of map.entries()) { + if (fn === handlerOrSlotKey) { + map.delete(key); + } + } + } + + if (map.size === 0) { + this._topics.delete(topic); + } + } + + /** + * Emit to all handlers on `topic`. Handlers returning + * 'remove_handler' are auto-removed. + * Returns the number of handlers invoked. + */ + emit(topic, ...args) { + let count = 0; + const map = this._topics.get(topic); + if (!map) return count; + + for (const [key, fn] of Array.from(map.entries())) { + const res = fn(...args); + count++; + if (res === 'remove_handler') { + map.delete(key); + } + } + + if (map.size === 0) { + this._topics.delete(topic); + } + return count; + } +} + +class QueryWrapper extends Array { + constructor(elements) { + super(); + if (elements) { + this.push(...(Array.isArray(elements) ? elements : [elements])); + } + } +} + +$ = function(selector_or_element) { + let elements; + if (selector_or_element instanceof Element || selector_or_element === document || selector_or_element === window) { + elements = [selector_or_element]; + } else if (typeof selector_or_element === 'string') { + // Check if string looks like HTML (starts with < and ends with >) + if (selector_or_element.trim().charAt(0) === '<' && selector_or_element.trim().charAt(selector_or_element.trim().length - 1) === '>') { + // Create element from HTML string + let temp = document.createElement('div'); + temp.innerHTML = selector_or_element.trim(); + elements = Array.from(temp.children); + } else { + // Treat as CSS selector + elements = Array.from(document.querySelectorAll(selector_or_element)); + } + } else if (selector_or_element instanceof NodeList || Array.isArray(selector_or_element)) { + elements = Array.from(selector_or_element); + } else { + elements = [selector_or_element]; + } + return new QueryWrapper(elements); +}; + +$.options = { + alwaysDoDifferentialUpdate: true, +}; + +$.events = new EventEmitter(); +$.on = $.events.on.bind($.events); +$.off = $.events.off.bind($.events); +$.emit = $.events.emit.bind($.events); + +$.each = function(selector, callback) { + let elements; + if (typeof selector === 'string') { + elements = document.querySelectorAll(selector); + } else if (selector instanceof QueryWrapper) { + elements = selector; + } else { + elements = selector; + } + for (let i = 0; i < elements.length; i++) { + callback(elements[i], i); + } +} + +$.post = function(url, data, callback) { + return $.ajax({ + method: 'POST', + url: url, + data: data, + success: callback, + error: function(xhr) { + console.error('POST request failed:', xhr); + } + }); +} + +$.get = function(url, callback) { + return $.ajax({ + method: 'GET', + url: url, + success: callback, + error: function(xhr) { + console.error('GET request failed:', xhr); + } + }); +} + +$.ajax = function(options) { + return new Promise((resolve, reject) => { + var xhr = new XMLHttpRequest(); + xhr.open(options.method || 'GET', options.url, true); + + // Set default headers + if (options.method === 'POST' || options.data) { + xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8'); + } + + // Set custom headers + if (options.headers) { + for (var key in options.headers) { + xhr.setRequestHeader(key, options.headers[key]); + } + } + + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + var response; + try { + response = JSON.parse(xhr.responseText); + } catch (e) { + // If JSON parsing fails, use raw response + response = xhr.responseText; + } + + if (xhr.status >= 200 && xhr.status < 300) { + if (options.success) options.success(response); + resolve(response); + } else { + if (options.error) options.error(xhr); + reject(xhr); + } + } + }; + + var data = null; + if (options.data) { + data = typeof options.data === 'string' ? options.data : JSON.stringify(options.data); + } + + xhr.send(data); + }); +} + +$.ready = function(callback) { + if (document.readyState !== 'loading') { + callback(); + } else { + document.addEventListener('DOMContentLoaded', callback); + } +} + +// Helper function to execute scripts in HTML content +function executeScripts(htmlContent) { + // Create a temporary container to parse the HTML + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = htmlContent; + + // Find all script tags and disable them temporarily + const scripts = tempDiv.querySelectorAll('script'); + const scriptData = []; + + scripts.forEach((script) => { + // Store script data for later execution + scriptData.push({ + content: script.textContent || script.innerText || '', + src: script.src, + type: script.type, + nonce: script.nonce, + async: script.async, + defer: script.defer + }); + + // Disable the script by changing its type + script.type = 'text/disabled-script'; + }); + + // Return the modified HTML and script data + return { + html: tempDiv.innerHTML, + scripts: scriptData + }; +} + +// Helper function to execute a single script +function executeScript(scriptInfo) { + if (scriptInfo.src) { + // External script + const newScript = document.createElement('script'); + if (scriptInfo.type && scriptInfo.type !== 'text/disabled-script') { + newScript.type = scriptInfo.type; + } + if (scriptInfo.nonce) newScript.nonce = scriptInfo.nonce; + if (scriptInfo.async) newScript.async = scriptInfo.async; + if (scriptInfo.defer) newScript.defer = scriptInfo.defer; + newScript.src = scriptInfo.src; + + document.head.appendChild(newScript); + } else if (scriptInfo.content.trim()) { + // Inline script + const newScript = document.createElement('script'); + if (scriptInfo.type && scriptInfo.type !== 'text/disabled-script') { + newScript.type = scriptInfo.type; + } + if (scriptInfo.nonce) newScript.nonce = scriptInfo.nonce; + newScript.text = scriptInfo.content; + + // Execute by inserting and immediately removing + document.head.appendChild(newScript); + document.head.removeChild(newScript); + } +} + +// Add all the jQuery-like methods to QueryWrapper +QueryWrapper.prototype.parent = function() { + const parents = Array.from(this).map(el => el.parentNode).filter(el => el); + return new QueryWrapper(parents); +} + +QueryWrapper.prototype.load = function(url, opt = {}) { + return new Promise((resolve, reject) => { + const ajaxOptions = { + method: opt.method || (opt.postData ? 'POST' : 'GET'), + url: url, + success: (response) => { + if(opt.replace) + this.replaceWith(response, opt); + else + this.html(response, opt); + if (opt.onLoad) opt.onLoad(response, null); + resolve(this); + }, + error: (xhr) => { + if (opt.onError) opt.onError(xhr); + reject(new Error('Failed to load: ' + url)); + } + }; + + if (opt.postData) { + ajaxOptions.data = opt.postData; + } + + $.ajax(ajaxOptions); + }); +} + +QueryWrapper.prototype.html = function(opt_content = false, prop = {}) { + if (opt_content === false) { + return Array.from(this).map(el => el.innerHTML).join(''); + } + if(typeof prop.diff == 'undefined') + prop.diff = $.options.alwaysDoDifferentialUpdate; + if(prop.diff === true) { + const temp = document.createElement('div'); + temp.innerHTML = opt_content; + this.forEach(el => { + morphdom(el, temp, { childrenOnly: true }); + }); + } else { + this.forEach(el => { + const result = executeScripts(opt_content); + el.innerHTML = result.html; + result.scripts.forEach(executeScript); + }); + } + return this; +} + +QueryWrapper.prototype.text = function(opt_content = false) { + if (opt_content === false) { + return Array.from(this).map(el => el.textContent).join(''); + } + this.forEach(el => { + el.textContent = opt_content; + }); + return this; +} + +QueryWrapper.prototype.replaceWith = function(content, prop = {}) { + if(typeof prop.diff == 'undefined') + prop.diff = $.options.alwaysDoDifferentialUpdate; + if(prop.diff === true) { + this.forEach(el => { + morphdom(el, content); + }); + } else { + if(typeof content === 'string') { + this.forEach(el => { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = content; + el.parentNode.replaceChild(tempDiv.firstChild, el); + }); + } else if(content instanceof Element) { + this.forEach(el => { + el.parentNode.replaceChild(content.cloneNode(true), el); + }); + } + } + return this; +} + +QueryWrapper.prototype.query = function(selector) { + const found = Array.from(this).reduce((acc, el) => { + const results = el.querySelectorAll(selector); + return acc.concat(Array.from(results)); + }, []); + return new QueryWrapper(found); +} + +QueryWrapper.prototype.find = function(selector) { + const found = Array.from(this).reduce((acc, el) => { + const results = el.querySelectorAll(selector); + return acc.concat(Array.from(results)); + }, []); + return new QueryWrapper(found); +} + +QueryWrapper.prototype.on = function(event, handler) { + this.forEach(function(el) { + el.addEventListener(event, handler); + }); + return this; +} + +QueryWrapper.prototype.off = function(event, handler) { + this.forEach(function(el) { + el.removeEventListener(event, handler); + }); + return this; +} + +QueryWrapper.prototype.hide = function() { + return this.css({ display: 'none' }); +} + +QueryWrapper.prototype.show = function() { + return this.css({ display: '' }); +} + +QueryWrapper.prototype.toggle = function() { + this.forEach(function(el) { + el.style.display = (el.style.display === 'none' || el.style.display === '') ? '' : 'none'; + }); + return this; +} + +QueryWrapper.prototype.attr = function(name, value) { + if (value === undefined) { + return this.length > 0 ? this[0].getAttribute(name) : null; + } + this.forEach(function(el) { + el.setAttribute(name, value); + }); + return this; +} + +QueryWrapper.prototype.addClass = function(classNameOrList) { + var classList = Array.isArray(classNameOrList) ? classNameOrList : [classNameOrList]; + this.forEach(function(el) { + classList.forEach(function(classNames) { + // Split space-separated class names + var classes = classNames.toString().split(/\s+/).filter(function(name) { return name.length > 0; }); + classes.forEach(function(className) { + el.classList.add(className); + }); + }); + }); + return this; +} + +QueryWrapper.prototype.removeClass = function(classNameOrList) { + var classList = Array.isArray(classNameOrList) ? classNameOrList : [classNameOrList]; + this.forEach(function(el) { + classList.forEach(function(classNames) { + // Split space-separated class names + var classes = classNames.toString().split(/\s+/).filter(function(name) { return name.length > 0; }); + classes.forEach(function(className) { + el.classList.remove(className); + }); + }); + }); + return this; +} + +QueryWrapper.prototype.toggleClass = function(classNameOrList, force) { + var classList = Array.isArray(classNameOrList) ? classNameOrList : [classNameOrList]; + this.forEach(function(el) { + classList.forEach(function(classNames) { + // Split space-separated class names + var classes = classNames.toString().split(/\s+/).filter(function(name) { return name.length > 0; }); + classes.forEach(function(className) { + if (typeof force !== 'undefined') { + el.classList.toggle(className, force); + } else { + el.classList.toggle(className); + } + }); + }); + }); + return this; +} + +QueryWrapper.prototype.empty = function() { + this.forEach(function(el) { + el.innerHTML = ''; + }); + return this; +} + +QueryWrapper.prototype.css = function(styles, optOrValue) { + // If no arguments or first argument is a string (getter mode) + if (arguments.length === 0 || (typeof styles === 'string' && arguments.length === 1)) { + if (this.length === 0) return undefined; + var el = this[0]; + if (typeof styles === 'string') { + // Get computed style for a specific property + return window.getComputedStyle(el)[styles]; + } else { + // Return computed styles object (not commonly used) + return window.getComputedStyle(el); + } + } + + // Setter mode + this.forEach(function(el) { + if (typeof styles === 'string' && arguments.length >= 2) { + // Single property setter: .css('prop', 'value') + el.style[styles] = optOrValue; + } else if (typeof styles === 'object') { + // Multiple properties setter: .css({prop1: 'value1', prop2: 'value2'}) + for (var key in styles) { + el.style[key] = styles[key]; + } + } + }); + return this; +} + +QueryWrapper.prototype.each = function(callback) { + this.forEach(function(el, index) { + callback(el, index); + }); + return this; +} + +QueryWrapper.prototype.append = function(child_or_html) { + this.forEach(function(el) { + if (typeof child_or_html === 'string' && el.insertAdjacentHTML) { + if (child_or_html.includes(' max) return max; + return v; +} + +function pick_entry_from_range(array, value) { + if (!array) return {}; + let result = {}; + for (const [pv] of Object.entries(array)) { + if (value >= pv.from && value <= pv.to) result = pv; + } + return result; +} + +return { + $: $, + EventEmitter: EventEmitter, + QueryWrapper: QueryWrapper, + first: first, + clamp: clamp, + pick_entry_from_range: pick_entry_from_range +}; + +})); \ No newline at end of file diff --git a/web/js/visualization.js b/web/js/visualization.js new file mode 100644 index 0000000..6144ffd --- /dev/null +++ b/web/js/visualization.js @@ -0,0 +1,176 @@ +(function () { + 'use strict'; + + const format = value => Number(Number(value).toPrecision(6)).toString(); + const palette = [[68,1,84],[59,82,139],[33,145,140],[94,201,98],[253,231,37]]; + + window.ExoVisualization = function (container) { + const root = container; + let index = null, frameUrl = null, seriesUrl = null, frameAbort = null, seriesAbort = null; + let frameToken = 0, seriesToken = 0, frame = null, chart = null, gl = null, texture = null, program = null; + let raf = 0, observer = null, destroyed = false, mode = 'map', time = 0, level = 0, field = '', min = 0, max = 1; + let rotation = 0, pitch = 0, drag = null, bounds = null, textureDirty = true, software = false; + let composite = null, rgbFrames = null, rendering = 'field'; + const raster = document.createElement('canvas'); + const rasterContext = raster.getContext('2d'); + root.innerHTML = `
No spatial data
`; + const $ = s => root.querySelector(s), map = $('.viz-map'), globe = $('.viz-globe'), chartCanvas = $('.viz-chart'), empty = $('.viz-empty'), tip = $('.viz-tooltip'); + const viewSelect = $('[data-view]'), fieldSelect = $('[data-field]'), levelSelect = $('[data-level]'), timeInput = $('[data-time]'); + const context = map.getContext('2d'); + $('.viz-legend div').style.background='linear-gradient(90deg,'+palette.map(c=>'rgb('+c.join(',')+')').join(',')+')'; + function currentField() { return (index && index.fields || []).find(x => x.name === field); } + function isComposite() { return rendering === 'rgb' && Boolean(composite); } + function showLegend() { + $('.viz-legend').hidden=isComposite();$('.viz-rgb-legend').hidden=!isComposite(); + const legend=$('.viz-rgb-legend');legend.replaceChildren(); + if(!isComposite())return; + composite.channels.forEach((channel,i)=>{const row=document.createElement('div'),name=document.createElement('span'),bar=document.createElement('i'),range=document.createElement('span');name.textContent=channel.label;range.textContent=`${format(channel.min)}–${format(channel.max)} ${channel.unit}`;bar.style.background=`linear-gradient(90deg,#000,${['#ff0000','#00ff00','#0000ff'][i]})`;row.append(name,bar,range);legend.append(row);}); + } + function setStatus(text) { empty.textContent = text; empty.hidden = false; } + function color(v) { + if (!Number.isFinite(v)) return [0,0,0,0]; + const t = max === min ? .5 : Math.max(0, Math.min(1, (v-min)/(max-min))); + const position=t*(palette.length-1),i=Math.min(palette.length-2,Math.floor(position)),fraction=position-i; + return palette[i].map((value,c)=>value+(palette[i+1][c]-value)*fraction); + } + function sizeCanvas(canvas,limit=Infinity) { const r=canvas.getBoundingClientRect(),d=Math.min(window.devicePixelRatio||1,2),scale=Math.min(1,limit/Math.max(r.width*d,r.height*d)),w=Math.max(1,Math.round(r.width*d*scale)),h=Math.max(1,Math.round(r.height*d*scale));if(canvas.width!==w)canvas.width=w;if(canvas.height!==h)canvas.height=h;return d*scale; } + function coords() { const lat=index&&index.lat||[], lon=index&&index.lon||[]; return {lat,lon}; } + function nearest(a,v,period=0) { let best=0,dist=Infinity;for(let i=0;i1?Number(lon[1])-Number(lon[0]):360; + bounds={latMin:-90,latMax:90,lonMin:Number(lon[0])-step/2,lonMax:Number(lon[0])+360-step/2,rows,cols}; + raster.width=cols; raster.height=height; + const image=rasterContext.createImageData(cols,height),rgb=isComposite(),nativeColors=rgb?new Uint8ClampedArray(rows*cols*4):null; + if(rgb)for(let row=0;row1)continue; + const z=Math.sqrt(1-squared),ny=qy*cp+z*sp,nz=z*cp-qy*sp,latitude=Math.asin(Math.max(-1,Math.min(1,ny))),longitude=Math.atan2(qx,nz)+rotation,u=((longitude/(2*Math.PI)+offset)%1+1)%1,v=.5-latitude/Math.PI,si=(Math.min(raster.height-1,Math.floor(v*raster.height))*raster.width+Math.min(raster.width-1,Math.floor(u*raster.width)))*4,light=isComposite()?1:.35+.65*Math.max(0,(-.4*qx+.5*qy+z)/lightLength),alpha=source[si+3]/255; + for(let channel=0;channel<3;channel++)image.data[p+channel]=image.data[p+channel]*(1-alpha)+source[si+channel]*light*alpha; + } + context.putImageData(image,0,0);textureDirty=false; + } + function redraw(){if(mode==='map')drawMap();else drawGlobe();} + function drawChart(values) { + const c=chartCanvas.getContext('2d'),d=sizeCanvas(chartCanvas),w=chartCanvas.width,h=chartCanvas.height; + c.clearRect(0,0,w,h);if(!values?.mean)return; + const valid=values.mean.filter(v=>v!==null&&Number.isFinite(Number(v))).map(Number); + if(!valid.length)return; + const lo=Math.min(...valid),hi=Math.max(...valid),left=60*d,right=w-12*d,top=18*d,bottom=h-20*d; + const times=values.time||index.time,start=Number(times[0]),end=Number(times.at(-1)); + c.fillStyle='#a8b7b9';c.font=`${10*d}px sans-serif`; + c.fillText(`Global mean ${isComposite()?'temperature ':''}${values.unit||''}`,left,12*d); + c.fillText(hi.toPrecision(4),0,top+8*d);c.fillText(lo.toPrecision(4),0,bottom); + c.fillText(`${format(start)} ${values.time_unit||''}`,left,h-3*d); + c.textAlign='right';c.fillText(format(end),right,h-3*d);c.textAlign='left'; + c.strokeStyle='#65b7d8';c.lineWidth=2*d;c.beginPath();let gap=true; + values.mean.forEach((v,i)=>{if(v===null||!Number.isFinite(Number(v))){gap=true;return;} + const x=left+(Number(times[i])-start)/(end-start||1)*(right-left),y=bottom-(Number(v)-lo)/(hi-lo||1)*(bottom-top); + if(gap)c.moveTo(x,y);else c.lineTo(x,y);gap=false; + if(values.mean.length===1)c.rect(x-2*d,y-2*d,4*d,4*d); + });c.stroke(); + } + async function readPlane(meta,signal) { + const response=await fetch(frameUrl(meta.name,Math.min(time,meta.shape[0]-1),isComposite()?0:level),{signal,credentials:'same-origin'}); + if(!response.ok)throw Error(`Frame request failed (${response.status})`); + const raw=new Float32Array(await response.arrayBuffer()),rows=meta.shape[2],cols=meta.shape[3]; + if(!rows||!cols||raw.length!==rows*cols)throw Error('Frame dimensions do not match the dataset index'); + return Array.from({length:rows},(_,r)=>raw.subarray(r*cols,(r+1)*cols)); + } + async function loadFrame() { + if(!index||!field||!frameUrl)return; + const token=++frameToken;if(frameAbort)frameAbort.abort();frameAbort=new AbortController();frame=null;rgbFrames=null;tip.hidden=true; + $('[data-inspector]').textContent='Loading field…';setStatus('Loading field…'); + const rgb=isComposite(),metas=rgb?composite.channels.map(channel=>index.fields.find(meta=>meta.name===channel.name)):[currentField()]; + try{ + const planes=await Promise.all(metas.map(meta=>readPlane(meta,frameAbort.signal))); + if(token!==frameToken||destroyed)return; + frame=planes[0];if(rgb)rgbFrames=planes;textureDirty=true; + const fieldMeta=metas[0];min=fieldMeta.min==null?NaN:Number(fieldMeta.min);max=fieldMeta.max==null?NaN:Number(fieldMeta.max); + $('[data-min]').textContent=Number.isFinite(min)?min.toPrecision(4):'';$('[data-max]').textContent=Number.isFinite(max)?max.toPrecision(4):''; + empty.hidden=true;redraw();if(inspected)inspectCell(...inspected);else $('[data-inspector]').textContent='Tap or point at the field to inspect a value.'; + }catch(e){if(e.name!=='AbortError'&&token===frameToken&&!destroyed){frameAbort.abort();const message=e.message||'Could not load field';setStatus(message);$('[data-inspector]').textContent=message;}} + } + async function loadSeries(){if(!index||!field||!seriesUrl)return;const token=++seriesToken;if(seriesAbort)seriesAbort.abort();seriesAbort=new AbortController();chart=null;drawChart(null);try{const response=await fetch(seriesUrl(field,level),{signal:seriesAbort.signal,credentials:'same-origin'});if(!response.ok)throw Error(`Series request failed (${response.status})`);const data=await response.json();if(token===seriesToken&&!destroyed){if(!data.ok)throw Error(data.error||'Could not load global means');chart=data.data;drawChart(chart);}}catch(e){if(e.name!=='AbortError'&&token===seriesToken)drawChart(null);} } + function changed(){ + const previous=rendering;rendering=viewSelect.value;const viewChanged=previous!==rendering; + const next=isComposite()?composite.channels[0].name:fieldSelect.value,old=field,oldLevel=level;field=next; + fieldSelect.closest('label').hidden=isComposite();showLegend(); + if(next!==old||!levelSelect.options.length){ + levelSelect.innerHTML='';const count=currentField()?.shape[1]||1; + for(let i=0;i{mode=mode==='map'?'globe':'map';map.hidden=mode!=='map'&&!software;globe.hidden=mode!=='globe'||software;$('[data-mode]').textContent=mode==='map'?'Globe':'Map';map.setAttribute('aria-label',mode==='map'?'Spatial field map. Use arrow keys to inspect cells.':'Spatial field globe. Drag or use arrow keys to rotate.');redraw();}); + let playTimer=0;const play=$('[data-play]');function stopPlayback(){if(playTimer)clearInterval(playTimer);playTimer=0;play.textContent='Play';play.setAttribute('aria-pressed','false');}play.addEventListener('click',()=>{if(playTimer){stopPlayback();return;}play.textContent='Pause';play.setAttribute('aria-pressed','true');playTimer=setInterval(()=>{if(document.hidden)return;const n=Number(timeInput.max);timeInput.value=String((Number(timeInput.value)+1)%(n+1));changed();},600);}); + let inspected=null; + function inspectCell(ri,ci){if(!frame)return;inspected=[ri,ci];const value=Number(frame[ri][ci]),{lat,lon}=coords(),position=`${Number(lat[ri]).toFixed(2)}°, ${Number(lon[ci]).toFixed(2)}°`; + const text=isComposite()?position+'\n'+composite.channels.map((channel,i)=>{const v=Number(rgbFrames[i][ri][ci])*channel.factor;return`${channel.label}: ${Number.isFinite(v)?v.toPrecision(5)+' '+channel.unit:'missing'}`;}).join('\n'):`${position} · ${Number.isFinite(value)?value.toPrecision(5)+(currentField().unit?' '+currentField().unit:''):'missing'}`;tip.textContent=text;$('[data-inspector]').textContent=text;} + function inspectPointer(event,canvas,latitude,longitude){const {lat,lon}=coords();inspectCell(nearest(lat,latitude),nearest(lon,longitude,360));const r=canvas.getBoundingClientRect();tip.hidden=false;tip.style.left=Math.max(0,Math.min(r.width-tip.offsetWidth-8,event.clientX-r.left+12))+'px';tip.style.top=Math.max(0,Math.min(r.height-tip.offsetHeight-8,event.clientY-r.top+12))+'px';} + function mapPointer(event){if(mode==='globe'){globePointer(event,map);return;}if(!frame||!bounds)return;const r=map.getBoundingClientRect(),x=(event.clientX-r.left)/r.width,y=(event.clientY-r.top)/r.height;inspectPointer(event,map,90-y*180,bounds.lonMin+x*360);} + map.addEventListener('pointermove',mapPointer); + map.addEventListener('pointerdown',event=>{if(mode==='globe')startDrag(event,map);}); + map.addEventListener('pointerup',event=>{drag=null;mapPointer(event);});map.addEventListener('pointercancel',()=>{drag=null;}); + map.addEventListener('pointerleave',()=>{tip.hidden=true;}); + map.addEventListener('keydown',event=>{if(mode==='globe'){rotateKeys(event);return;}if(!frame)return;let [ri,ci]=inspected||[Math.floor(frame.length/2),Math.floor(frame[0].length/2)];if(event.key==='ArrowUp')ri--;else if(event.key==='ArrowDown')ri++;else if(event.key==='ArrowLeft')ci--;else if(event.key==='ArrowRight')ci++;else return;event.preventDefault();inspectCell(Math.max(0,Math.min(frame.length-1,ri)),(ci+frame[0].length)%frame[0].length);}); + function startDrag(event,canvas){drag={x:event.clientX,y:event.clientY,rot:rotation,pitch};canvas.setPointerCapture(event.pointerId);tip.hidden=true;} + function globePointer(event,canvas){if(drag){rotation=drag.rot+(event.clientX-drag.x)/Math.max(1,canvas.clientWidth)*Math.PI*2;pitch=Math.max(-Math.PI/2,Math.min(Math.PI/2,drag.pitch+(event.clientY-drag.y)/Math.max(1,canvas.clientHeight)*Math.PI));if(!raf)raf=requestAnimationFrame(()=>{raf=0;redraw();});return;}if(!frame||mode!=='globe')return;const r=canvas.getBoundingClientRect(),x=((event.clientX-r.left)/r.width*2-1)*Math.max(1,r.width/r.height),y=(1-(event.clientY-r.top)/r.height*2)/Math.min(1,r.width/r.height),squared=x*x+y*y;if(squared>1){tip.hidden=true;return;}const z=Math.sqrt(1-squared),ny=y*Math.cos(pitch)+z*Math.sin(pitch),nz=z*Math.cos(pitch)-y*Math.sin(pitch);inspectPointer(event,canvas,Math.asin(Math.max(-1,Math.min(1,ny)))*180/Math.PI,(Math.atan2(x,nz)+rotation)*180/Math.PI);} + globe.addEventListener('pointerdown',event=>startDrag(event,globe));globe.addEventListener('pointermove',event=>globePointer(event,globe)); + globe.addEventListener('pointerup',event=>{drag=null;globePointer(event,globe);});globe.addEventListener('pointercancel',()=>{drag=null;});globe.addEventListener('pointerleave',()=>{tip.hidden=true;}); + function rotateKeys(event){if(event.key==='ArrowLeft')rotation-=.1;else if(event.key==='ArrowRight')rotation+=.1;else if(event.key==='ArrowUp')pitch+=.1;else if(event.key==='ArrowDown')pitch-=.1;else if(event.key==='Home'){rotation=0;pitch=0;}else return;event.preventDefault();pitch=Math.max(-Math.PI/2,Math.min(Math.PI/2,pitch));redraw();} + globe.addEventListener('keydown',rotateKeys); + globe.addEventListener('webglcontextlost',event=>{event.preventDefault();gl=null;program=null;texture=null;textureDirty=true;software=true;redraw();}); + globe.addEventListener('webglcontextrestored',()=>{software=false;textureDirty=true;redraw();}); + const visibility=()=>{if(!document.hidden)redraw();}; + observer=new ResizeObserver(()=>{if(!document.hidden){redraw();drawChart(chart);}});observer.observe(root);document.addEventListener('visibilitychange',visibility); + return { setDataset(next,frameFn,seriesFn,options={}){stopPlayback();inspected=null;tip.hidden=true;$('[data-inspector]').textContent='Point at the map to inspect a value.';index=next||null;composite=window.ExoComposite?.create(index,options.gravity)||null;viewSelect.querySelector('[value="rgb"]').disabled=!composite;viewSelect.closest('label').hidden=!composite;if(!composite)viewSelect.value='field';rendering=viewSelect.value;rgbFrames=null;frameUrl=frameFn;seriesUrl=seriesFn;frame=null;chart=null;textureDirty=true;if(frameAbort)frameAbort.abort();if(seriesAbort)seriesAbort.abort();frameToken++;seriesToken++;const previousField=fieldSelect.value,previousLevel=level;fieldSelect.innerHTML='';levelSelect.innerHTML='';const fields=index&&index.fields||[];for(const f of fields){const o=document.createElement('option');o.value=f.name;o.textContent=(f.label||f.name).replaceAll('_',' ')+(f.unit?' ('+f.unit+')':'');fieldSelect.appendChild(o);}field=(fields.find(f=>f.name===previousField)||fields.find(f=>f.name==='ts')||fields.find(f=>f.name==='elevation')||fields[0])?.name||'';fieldSelect.value=field;const count=currentField()?.shape[1]||0;levelSelect.closest('label').hidden=count<=1;for(let i=0;i0;setStatus(fields.length?'Loading field…':'No spatial data');if(fields.length)changed();else{drawChart(null);}}, destroy(){destroyed=true;if(frameAbort)frameAbort.abort();if(seriesAbort)seriesAbort.abort();if(observer)observer.disconnect();if(raf)cancelAnimationFrame(raf);stopPlayback();document.removeEventListener('visibilitychange',visibility);if(gl){gl.deleteTexture(texture);gl.deleteProgram(program);}root.innerHTML='';} }; + }; +})(); diff --git a/web/js/workbench.js b/web/js/workbench.js new file mode 100644 index 0000000..494606e --- /dev/null +++ b/web/js/workbench.js @@ -0,0 +1,85 @@ +(function startWorkbench() { +'use strict'; +if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',startWorkbench,{once:true});return;} +const boot=window.EXOPLASIM||{},api=boot.api||{},$=(s,r=document)=>r.querySelector(s); +const state={schema:null,experiments:[],jobs:[],experiment:null,draftName:'',spec:null,dirty:false,job:null,logOffset:0,logTimer:null,pollTimer:null,visual:null,saveVersion:0,editVersion:0,saving:false,deleting:false,selection:0,pollBusy:false,resultLoaded:'',results:null,mapEditor:null,mapDirty:false,invalidFields:new Map()}; +const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const notice=(t,error=false)=>{const e=$('#workbench-notice');e.textContent=t;e.classList.toggle('error-text',error);e.classList.add('visible');clearTimeout(notice.timer);notice.timer=setTimeout(()=>e.classList.remove('visible'),4500);}; +const endpoint=n=>api[n]||''; +function apiUrl(name,values={}){const q=Object.entries(values).filter(([,v])=>v!==undefined&&v!==null&&v!=='').map(([k,v])=>encodeURIComponent(k)+'='+encodeURIComponent(v)).join('&');return endpoint(name)+(q?'&'+q:'');} +async function request(name,o={}){const u=apiUrl(name,o.query);const h=new Headers(o.headers||{});if(o.body!==undefined){h.set('Content-Type','application/json');}const r=await fetch(u,{method:o.method||'GET',headers:h,body:o.body===undefined?undefined:JSON.stringify(o.body),signal:o.signal,credentials:'same-origin'});if(['frame','map'].includes(name)&&r.ok)return r.arrayBuffer();if(name==='download')return r;const p=await r.json().catch(()=>({ok:false,error:`Request failed (${r.status})`}));if(!r.ok||!p.ok){const e=new Error(p.error||`Request failed (${r.status})`);e.errors=p.errors||[];e.status=r.status;throw e;}return p.data;} +function setDirty(v=true){if(v)state.editVersion++;state.dirty=v;$('#save-state').textContent=v?'Unsaved changes':'Saved';$('#save-state').classList.toggle('dirty',v);} +function freshSpec(){const s=state.schema||{};return{model:structuredClone(s.model_defaults||{}),parameters:{},run:structuredClone(s.run_defaults||{})};} +function setExperiment(e){state.experiment=e;state.draftName=e.name;state.spec=structuredClone(e.spec);state.spec.parameters={...state.spec.parameters};state.invalidFields.clear();setDirty(false);renderAll();} +function renderExperiments(){const t=$('#experiment-search').value.toLowerCase();$('#experiment-list').innerHTML=state.experiments.map(e=>`
  • `).join('');} +function renderConstructor(){const m=state.spec.model,r=state.spec.run,s=$('#model-resolution');s.innerHTML='';const choices=state.schema.model?.resolution?.choices||state.schema.model_metadata?.resolution?.choices||Object.keys(state.schema.model_metadata||{});for(const c of choices){const o=document.createElement('option');o.value=c;o.textContent=c;s.append(o);}s.value=m.resolution||'';$('#model-layers').value=m.layers??'';$('#model-ncpus').value=m.ncpus??'';$('#run-cycles').value=r.cycles??'';$('#run-steps').value=r.steps??'';$('#experiment-name').value=state.draftName;$('#active-experiment-title').textContent=state.draftName||'New experiment';} +function inputFor(f,v){const k=esc(f.name),disabled=f.editable===false?'disabled':'';let h;if(f.type==='boolean')h=``;else if(f.type==='object'||f.type==='array')h=``;else if(f.choices)h=``;else h=``;if(f.nullable)h+=``;return h;} +function renderParameters(){const fs=(state.schema.fields||[]).filter(f=>f.editable),term=$('#parameter-search').value.toLowerCase(),groups=new Map();for(const f of fs){const g=f.group||'Other';if(!groups.has(g))groups.set(g,[]);groups.get(g).push(f);}let total=0,shown=0,h='';for(const[g,items]of groups){const before=shown,rows=items.map(f=>{const v=Object.hasOwn(state.spec.parameters,f.name)?state.spec.parameters[f.name]:f.default,show=`${f.name} ${f.label||''} ${f.description||''} ${g}`.toLowerCase().includes(term);total++;if(show)shown++;return`
    ${inputFor(f,state.invalidFields.has(f.name)?state.invalidFields.get(f.name):v)}${f.unit?`${esc(f.unit)}`:''}
    `;}).join('');if(shown>before)h+=`
    ${esc(g)}
    ${rows}
    `;}$('#parameter-fields').innerHTML=h;$('#parameter-count').textContent=`${shown} / ${total}`;} +function actions(){document.querySelectorAll('[data-action="save"],[data-action="save-run"]').forEach(b=>b.disabled=state.saving||state.deleting||!state.schema);const has=!!state.experiment;document.querySelectorAll('[data-action="clone"],[data-action="delete"],[data-action="export"]').forEach(b=>b.disabled=!has||(b.dataset.action==='delete'&&state.deleting));document.querySelector('[data-action="cancel"]').disabled=!state.job||!['queued','running'].includes(state.job.status);document.querySelector('[data-action="retry"]').disabled=!state.job||!['failed','cancelled','interrupted'].includes(state.job.status);const failure=$('#job-error'),message=state.job?.error||'';if(failure.textContent!==message)failure.textContent=message;failure.hidden=!message;} +function renderAll(){renderExperiments();renderConstructor();renderParameters();renderMaps();actions();} +function renderMaps(){$('.surface-map-controls').hidden=!state.schema.map_fields;const keys=Object.keys(state.spec.maps||{});$('#surface-map-status').textContent=keys.map(key=>state.schema.map_fields?.[key]?.label||key).join(' · ');$('[data-action="clear-maps"]').disabled=!keys.length;} +function mapValues(buffer){if(buffer.byteLength%4)throw Error('Map byte count is invalid.');const view=new DataView(buffer),values=new Float32Array(buffer.byteLength/4);for(let i=0;ij.kind==='planet'&&j.status==='completed'&&j.spec.model.resolution===resolution&&owner.surface&&Object.keys(state.schema.surface).every(k=>j.spec.surface?.[k]===owner.surface[k]));if(job){const result=await request('results',{query:{id:job.id}});terrain={job:job.id,index:result.dataset||result.index};}if(owner!==state.spec)return;const dialog=$('#map-editor-dialog');if(state.mapEditor)state.mapEditor.destroy();state.mapEditor=new window.ExoMapEditor($('#map-editor'),{ +fields,onDirty:dirty=>{state.mapDirty=dirty;}, sources:async field=>{const saved=versions.filter(v=>v.field===field),entries=saved.map(v=>({id:v.id,name:v.name}));if(owner.maps?.[field])entries.sort((a,b)=>(b.id===owner.maps[field])-(a.id===owner.maps[field]));if(terrain&&['height','landmask'].includes(field)){const entry={id:'terrain',name:'Generated terrain'};owner.maps?.[field]?entries.push(entry):entries.unshift(entry);}entries.push({id:'blank',name:'Uniform map'});return entries;}, +load:async(field,id)=>{if(id==='blank'){const values=new Float32Array(grid.lat.length*grid.lon.length);values.fill(fields[field].default??0);return{values,grid,name:fields[field].label};}if(id==='terrain'){const buffer=await request('frame',{query:{id:terrain.job,dataset:terrain.index.id,field:field==='height'?'elevation':'landmask',time:0,level:0}});return{values:mapValues(buffer),grid:{lat:terrain.index.lat,lon:terrain.index.lon},name:`${state.draftName} ${fields[field].label}`};}return{values:mapValues(await request('map',{query:{id}})),grid,name:versions.find(v=>v.id===id).name};}, +save:async(field,name,values)=>{const buffer=new ArrayBuffer(values.length*4),view=new DataView(buffer);for(let i=0;i{if(owner!==state.spec)return;state.spec.maps={...state.spec.maps,[field]:saved.id};setDirty();renderMaps();} +});dialog.showModal();await state.mapEditor.start();}catch(error){notice(error.message,true);}} +function closeMaps(){if(state.mapEditor?.canClose()){state.mapDirty=false;state.mapEditor.destroy();state.mapEditor=null;$('#map-editor-dialog').close();}} +function showErrors(e){const x=e.errors||[];$('#editor-errors').textContent=Array.isArray(x)?x.map(v=>typeof v==='string'?v:`${v.field||''} ${v.message||v.error||''}`).join('\n'):JSON.stringify(x);} +async function loadExperiments(){const d=await request('experiments');state.experiments=d.experiments||d.items||d||[];renderExperiments();} +async function loadJobs(){const d=await request('jobs');state.jobs=d.jobs||d.items||d||[];renderJobs();} +function receivedJob(job){const position=state.jobs.findIndex(item=>item.id===job.id);if(position<0)state.jobs.unshift(job);else state.jobs[position]=job;renderJobs();} +function renderJobs(){const markup=state.jobs.map(j=>`
  • `).join('');if(markup!==state.jobsMarkup){const focused=document.activeElement.closest('[data-job]')?.dataset.job;$('#job-list').innerHTML=markup;state.jobsMarkup=markup;if(focused)$('#job-list').querySelector(`[data-job="${focused}"]`)?.focus({preventScroll:true});}} +function clearVisualization(){if(state.visual){state.visual.destroy();state.visual=null;}$('#visualization').innerHTML=`
    ${state.job&&['completed','failed','cancelled','running'].includes(state.job.status)?'Loading spatial output…':'No spatial output yet.'}
    `;$('#visualization-downloads').innerHTML='';} +async function loadLog(reset=false,token=state.selection){if(!state.job)return;if(reset){state.logOffset=0;$('#job-log').textContent='';}try{const d=await request('log',{query:{id:state.job.id,offset:state.logOffset}});if(token!==state.selection)return;const t=d.text??d.log??'';$('#job-log').textContent=($('#job-log').textContent+t).slice(-65536);state.logOffset=d.next_offset??state.logOffset+new TextEncoder().encode(t).length;$('#log-cursor').textContent=t?`+${state.logOffset} bytes`:'';}catch(e){if(token===state.selection)$('#log-cursor').textContent=e.message;}} +function mountVisualization(d){ + const c=$('#visualization');if(state.visual)state.visual.destroy();if(!window.ExoVisualization){c.textContent='Visualization module is not available.';return;} + const visual=state.visual=new window.ExoVisualization(c),jobId=state.job.id,gravity=state.job.spec?.parameters?.gravity??state.schema?.fields.find(meta=>meta.name==='gravity')?.implicit_default; + let datasetRequest=0,downloadDataset='';const ds=d.datasets||[],initial=d.dataset||d.index||d,toolbar=$('#visualization-downloads'); + const select=document.createElement('select');select.className='dataset-select';select.setAttribute('aria-label','Dataset'); + for(const x of ds){const o=document.createElement('option');o.value=x.id;o.textContent=x.label||x.id;o.selected=x.id===initial.id;select.append(o);}if(ds.length>1)toolbar.append(select); + const menu=document.createElement('select'),link=document.createElement('a');menu.className='dataset-select';menu.setAttribute('aria-label','Download file');link.textContent='Download';toolbar.append(menu,link); + const update=()=>{link.href=apiUrl('download',{id:jobId,artifact:menu.value});};menu.addEventListener('change',update); + function downloads(links,index){ + const preferred=menu.value?menu.value.replace(':'+downloadDataset,':'+index.id):'';downloadDataset=index.id;menu.replaceChildren(); + for(const artifact of links){const option=document.createElement('option');option.value=artifact.id;option.textContent=artifact.label||artifact.id;menu.append(option);} + menu.hidden=link.hidden=!links.length;if(!links.length)return; + menu.value=(links.find(artifact=>artifact.id===preferred)||links.find(artifact=>artifact.id==='raw:'+index.id||artifact.id.startsWith('raw:'+index.id+':'))||links[0]).id;update(); + } + const use=(index,links)=>{visual.setDataset(index,(f,t,l)=>apiUrl('frame',{id:jobId,dataset:index.id,field:f,time:t,level:l}),(f,l)=>apiUrl('series',{id:jobId,dataset:index.id,field:f,level:l}),{gravity});downloads(links,index);};use(initial,d.downloads||[]); + select.addEventListener('change',async()=>{const current=++datasetRequest;try{const x=await request('results',{query:{id:jobId,dataset:select.value}});if(visual!==state.visual||current!==datasetRequest)return;use(x.dataset||x.index||x,x.downloads||[]);}catch(e){if(visual===state.visual&¤t===datasetRequest)notice(e.message,true);}}); +} +async function loadResult(token=state.selection){if(!state.job||!['completed','failed','cancelled','running'].includes(state.job.status)||state.resultLoaded===`${state.job.id}:${state.job.completed_cycles||0}`)return;const id=state.job.id,cycle=state.job.completed_cycles||0;try{const d=await request('results',{query:{id}});if(token!==state.selection||id!==state.job?.id)return;state.resultLoaded=`${id}:${cycle}`;mountVisualization(d);}catch(e){if(token===state.selection){state.resultLoaded=`${id}:${cycle}`;$('#visualization').textContent=e.status===404?'No spatial output yet.':e.message;if(e.status!==404)notice(e.message,true);}}} +async function selectJob(id){const token=++state.selection;state.job=state.jobs.find(x=>x.id===id)||null;document.querySelectorAll('[data-job]').forEach(button=>button.setAttribute('aria-current',button.dataset.job===id));state.resultLoaded='';clearVisualization();state.logOffset=0;$('#job-log').textContent='';await loadLog(true,token);if(token!==state.selection)return;await loadResult(token);actions();} +async function save(){if(state.saving||state.deleting)return false;const invalid=$('.parameter-pane input[type="number"]:invalid');if(invalid){invalid.reportValidity();invalid.focus();notice('Correct the invalid number before saving.',true);return false;}if(state.invalidFields.size){notice('Correct the invalid parameter values before saving.',true);return false;}const name=$('#experiment-name').value.trim();if(!name){notice('Enter an experiment name.',true);return false;}state.draftName=name;state.spec.model.resolution=$('#model-resolution').value;for(const k of ['layers','ncpus'])state.spec.model[k]=Number($(`#model-${k}`).value);state.spec.run.cycles=Number($('#run-cycles').value);state.spec.run.steps=$('#run-steps').value===''?null:Number($('#run-steps').value);const v=++state.saveVersion,revision=state.editVersion,editor=state.spec;state.saving=true;actions();$('#editor-errors').textContent='';try{const d=await request('experiments',{method:'POST',body:{...(state.experiment?.id?{id:state.experiment.id}:{}),name,spec:structuredClone(state.spec)}}),e=d.experiment||d;state.experiments=[e,...state.experiments.filter(item=>item.id!==e.id)];renderExperiments();if(v!==state.saveVersion||editor!==state.spec)return false;state.experiment=e;if(revision!==state.editVersion){notice('Earlier values saved. Current edits are not saved.');return false;}state.draftName=e.name;state.spec=structuredClone(e.spec);state.spec.parameters={...state.spec.parameters};setDirty(false);renderAll();notice('Experiment saved.');return true;}catch(e){showErrors(e);notice(e.message,true);return false;}finally{state.saving=false;actions();}} +async function deleteExperiment(){ + if(state.deleting||!state.experiment)return; + const selected=state.experiment,revision=state.editVersion; + if(!confirm(`Delete ${selected.name}?`))return; + state.deleting=true;actions(); + try{ + await request('experiment_delete',{method:'POST',body:{id:selected.id}}); + state.experiments=state.experiments.filter(item=>item.id!==selected.id); + if(state.experiment?.id===selected.id){ + state.experiment=null; + if(revision===state.editVersion){state.draftName='New experiment';state.spec=freshSpec();state.invalidFields.clear();} + setDirty();renderAll(); + }else renderExperiments(); + notice('Experiment deleted.'); + }catch(error){notice(error.message,true);}finally{state.deleting=false;actions();} +} +async function enqueue(){if(state.dirty&&!await save())return;if(!state.experiment)return;try{const j=await request('jobs',{method:'POST',body:{experiment_id:state.experiment.id}});receivedJob(j);if(j.id)await selectJob(j.id);notice('Run added to queue.');}catch(e){notice(e.message,true);}} +async function act(n,b){try{const d=await request(n,{method:'POST',body:b});receivedJob(d);if(n==='retry'&&d.id)await selectJob(d.id);else if(state.job)await selectJob(state.job.id);notice('Run updated.');}catch(e){notice(e.message,true);}} +function exportExperiment(){if(!state.experiment)return;const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([JSON.stringify({name:state.experiment.name,spec:state.experiment.spec},null,2)],{type:'application/json'}));a.download=`${state.experiment.name.replace(/[^a-z0-9_-]+/gi,'-')}.json`;a.click();URL.revokeObjectURL(a.href);} +async function importExperiment(file){try{const p=JSON.parse(await file.text());if(!p?.spec||typeof p.spec!=='object'||Array.isArray(p.spec)||['model','parameters','run','surface'].some(k=>p.spec[k]!==undefined&&(!p.spec[k]||typeof p.spec[k]!=='object'||Array.isArray(p.spec[k]))))throw Error('This file does not contain a valid experiment.');state.invalidFields.clear();state.experiment=null;state.draftName=String(p.name||'Imported experiment');const defaults=freshSpec();state.spec={...structuredClone(p.spec),model:{...defaults.model,...p.spec.model},parameters:{...p.spec.parameters},run:{...defaults.run,...p.spec.run}};setDirty();renderAll();notice('Imported parameters. Save the experiment to keep it.');}catch(e){notice(e.message,true);}} +async function poll(){if(state.pollBusy||document.hidden)return;state.pollBusy=true;try{await loadJobs();if(state.job){const old=state.job.status;state.job=state.jobs.find(x=>x.id===state.job.id)||state.job;if(old!==state.job.status){state.resultLoaded='';clearVisualization();if(state.job.kind==='planet'&&state.job.status==='completed')await loadExperiments();}else if(state.job.status==='running'&&state.resultLoaded!==`${state.job.id}:${state.job.completed_cycles||0}`){state.resultLoaded='';}if(['queued','running'].includes(state.job.status))await loadLog();if(state.resultLoaded!==state.job.id)await loadResult();actions();}}catch(e){notice(e.message,true);}finally{state.pollBusy=false;}} +function randomSeed(){return crypto.getRandomValues(new Uint32Array(1))[0];} +document.addEventListener('click',async e=>{const x=e.target.closest('[data-experiment]');if(x){if(state.dirty&&!confirm('Discard unsaved changes?'))return;setExperiment(state.experiments.find(v=>v.id===x.dataset.experiment));return;}const j=e.target.closest('[data-job]');if(j){await selectJob(j.dataset.job);return;}const b=e.target.closest('[data-action]');if(!b)return;if(!state.schema&&!['refresh','cancel','retry'].includes(b.dataset.action)){notice('Model parameters are not loaded. Select Refresh to try again.',true);return;}switch(b.dataset.action){case'edit-maps':await openMaps();break;case'close-maps':closeMaps();break;case'clear-maps':if(confirm('Use the original surface maps?')){delete state.spec.maps;setDirty();renderMaps();}break;case'new':if(state.dirty&&!confirm('Discard unsaved changes?'))return;state.experiment=null;state.draftName='New experiment';state.spec=freshSpec();state.invalidFields.clear();setDirty();renderAll();break;case'save':await save();break;case'save-run':if(await save())await enqueue();break;case'clone':if(state.experiment){state.draftName=`${state.experiment.name} copy`;state.experiment=null;setDirty();renderAll();}break;case'delete':await deleteExperiment();break;case'export':exportExperiment();break;case'import':$('#import-file').click();break;case'refresh':if(!state.schema)await init();else await Promise.all([loadExperiments(),loadJobs()]);break;case'cancel':if(state.job)await act('cancel',{id:state.job.id});break;case'retry':if(state.job)await act('retry',{id:state.job.id});break;case'reset':if(confirm('Reset parameters to model defaults?')){state.spec=freshSpec();state.invalidFields.clear();setDirty();renderAll();}break;case'generate':{const d=$('#generator-dialog'),f=$('#generator-form'),rules=state.schema.surface;for(const[k,rule]of Object.entries(rules)){const input=f.elements.namedItem(k);input.value=k==='seed'?randomSeed():rule.default;input.min=rule.min;input.max=rule.max;input.step=rule.type==='integer'?'1':'any';}d.showModal();break;}case'regenerate':$('#generator-form').seed.value=randomSeed();break;}}); +document.addEventListener('input',e=>{if(e.target.id==='experiment-name'){state.draftName=e.target.value;$('#active-experiment-title').textContent=state.draftName||'New experiment';setDirty();}if(e.target.id==='experiment-search')renderExperiments();if(e.target.id==='parameter-search')renderParameters();if(e.target.matches('[data-param]')){const f=(state.schema.fields||[]).find(x=>x.name===e.target.dataset.param),v=e.target.value;if(f?.type==='object'||f?.type==='array'){try{state.spec.parameters[f.name]=v.trim()===''?null:JSON.parse(v);state.invalidFields.delete(f.name);$('#editor-errors').textContent='';setDirty();}catch{state.invalidFields.set(f.name,v);setDirty();$('#editor-errors').textContent=`${f.name}: Enter valid JSON.`;}}else if((f?.type==='integer'||f?.type==='number')&&e.target.validity.badInput){state.invalidFields.set(f.name,v);setDirty();$('#editor-errors').textContent=`${f.name}: Enter a valid number.`;}else{const value=(f?.type==='integer'||f?.type==='number')?(v===''?null:Number(v)):f?.type==='boolean'?v==='true':v;if(f?.type==='integer'&&value!==null&&!Number.isInteger(value)){state.invalidFields.set(f.name,v);$('#editor-errors').textContent=`${f.name}: Enter a whole number.`;}else{state.invalidFields.delete(f.name);state.spec.parameters[f.name]=value;}setDirty();}}if(e.target.matches('[data-param]')){const key=e.target.dataset.param,toggle=e.target.closest('.parameter-row').querySelector('[data-null]');if(toggle)toggle.checked=!state.invalidFields.has(key)&&state.spec.parameters[key]===null;if(!state.invalidFields.size)$('#editor-errors').textContent='';}if(e.target.closest('.constructor-grid'))setDirty();}); +document.addEventListener('change',e=>{if(e.target.id==='import-file'&&e.target.files[0])importExperiment(e.target.files[0]);if(e.target.matches('[data-null]')){const k=e.target.dataset.null,control=e.target.closest('.parameter-row').querySelector('[data-param]');if(e.target.checked){state.spec.parameters[k]=null;state.invalidFields.delete(k);if(!state.invalidFields.size)$('#editor-errors').textContent='';control.value='';}else if(control.value!==''){control.dispatchEvent(new Event('input',{bubbles:true}));}else{state.invalidFields.set(k,'');$('#editor-errors').textContent=`${k}: Enter a value or select Default.`;control.focus();}setDirty();}if(e.target.id.startsWith('model-'))state.spec.model[e.target.id.slice(6)]=e.target.id==='model-resolution'?e.target.value:Number(e.target.value);if(e.target.id.startsWith('run-'))state.spec.run[e.target.id.slice(4)]=e.target.id==='run-steps'?(e.target.value===''?null:Number(e.target.value)):Number(e.target.value);}); +$('#map-editor-dialog').addEventListener('cancel',e=>{e.preventDefault();closeMaps();}); +$('#generator-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,status=$('#generator-status');const form=new FormData(f),surface={seed:Number(form.get('seed')),land_fraction:Number(form.get('land_fraction')),relief:Number(form.get('relief')),scale:Number(form.get('scale'))},rules=state.schema.surface;for(const[k,v]of Object.entries(surface)){const rule=rules[k]||{};if((typeof rule.min==='number'&&vrule.max)){status.textContent=`${k} must be between ${rule.min} and ${rule.max}.`;return;}}status.textContent='';try{const data=await request('generate',{method:'POST',body:{name:state.draftName&&state.draftName!=='New experiment'?state.draftName:`Planet ${surface.seed}`,spec:{...structuredClone(state.spec),surface},surface}});$('#generator-dialog').close();receivedJob(data.job);notice('Planet generation added to queue.');if(data.job?.id)await selectJob(data.job.id);}catch(x){status.textContent=x.message;showErrors(x);}}); +window.addEventListener('beforeunload',e=>{if(state.dirty||state.mapDirty){e.preventDefault();e.returnValue='';}}); +async function init(){actions();try{state.schema=await request('schema');state.spec=freshSpec();state.draftName='New experiment';await Promise.all([loadExperiments(),loadJobs()]);renderAll();$('#workbench-notice').classList.remove('visible');if(state.jobs.length)await selectJob(state.jobs[0].id);}catch(e){notice(e.message,true);}if(!state.pollTimer)state.pollTimer=setInterval(poll,3500);if(!state.logTimer)state.logTimer=setInterval(()=>{if(state.job&&['queued','running'].includes(state.job.status)&&!document.hidden)loadLog();},1800);}init(); +})(); \ No newline at end of file diff --git a/web/lib/components.php b/web/lib/components.php new file mode 100644 index 0000000..9f949c6 --- /dev/null +++ b/web/lib/components.php @@ -0,0 +1,262 @@ + function($prop, &$context) { + $context['current_component_id'] = $prop['id']; + return('
    '); + }, + + 'end' => function($prop) { + ?>'); + }, + + 'render' => function($prop, &$context) { + return($context['begin']($prop, $context) . $context['end']($prop)); + }, + + 'about' => 'This is an example component that wraps content in a div and logs when it is finalized.', + + ]; */ + + $GLOBALS['render_funcs'] = array(); + $GLOBALS['id_counter'] = $GLOBALS['id_counter'] ?? 1; + + function component_error_banner($s) + { + return ''; + } + + function component_expand_search_paths($search_path = false) + { + $paths = array(); + if(is_array($search_path)) + $paths = $search_path; + else if(is_string($search_path) && trim($search_path) !== '') + $paths = array($search_path); + + $normalized = array(); + foreach($paths as $path) + { + $base = rtrim((string)$path, '/'); + if($base === '') + continue; + $normalized[] = $base; + if(substr($base, -11) !== '/components') + $normalized[] = $base.'/components'; + } + return array_values(array_unique($normalized)); + } + + function component_caller_dir() + { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 8); + foreach($trace as $frame) + { + if(!isset($frame['file']) || !is_string($frame['file'])) + continue; + if(realpath($frame['file']) === realpath(__FILE__)) + continue; + return dirname($frame['file']); + } + return getcwd(); + } + + function component_normalize_name($file_name) + { + return preg_replace('/\.php$/', '', trim((string)$file_name)); + } + + function component_resolve_file($file_name, $search_path = false) + { + $component_name = component_normalize_name($file_name); + $candidates = array($component_name.'.php'); + if(!str_starts_with($component_name, 'components/')) + $candidates[] = 'components/'.$component_name.'.php'; + if(!str_starts_with($component_name, 'components/')) + { + foreach(component_expand_search_paths($search_path) as $path) + $candidates[] = $path.'/'.$component_name.'.php'; + } + if(!str_starts_with($component_name, 'interface/')) + $candidates[] = 'interface/'.$component_name.'.php'; + if(!str_starts_with($component_name, 'packages/')) + $candidates[] = 'packages/'.$component_name.'.php'; + foreach(array_unique($candidates) as $candidate) + { + if(file_exists($candidate)) + return $candidate; + } + return false; + } + + /** + * Loads a component from file system and registers it in the global registry + * + * Component files are searched in this order: + * 1. exact file path passed to the loader + * 2. components/{component_name}.php + * 3. caller-relative path and caller/components/{component_name}.php + * 4. interface/{component_name}.php and packages/{component_name}.php + * + * Component files should return an array with render functions like: + * return ['render' => function($prop) { ... }]; + * ERP-style files that return a single callable are treated as the default render function. + */ + function component_get_func($file_name, $return_false_if_not_found = false, $search_path = false) + { + $component_name = component_normalize_name($file_name); + $result = []; + // Check if component is already loaded in global registry + if(!isset($GLOBALS['render_funcs'][$component_name])) + { + $component_file = component_resolve_file($component_name, $search_path); + if($component_file) + { + $result['component_file'] = $component_file; + } + else + { + // Component file not found - create error component + if($return_false_if_not_found) return(false); + $result['component_file'] = false; + $result['error'] = 'Component not found: '.$component_name; + $result['render'] = function() use($component_name) { + return component_error_banner('component not found: '.$component_name); + }; + } + if($result['component_file']) + { + $component_definition = require($result['component_file']); + if(is_callable($component_definition)) + $result['render'] = $component_definition; + else if(is_array($component_definition)) + { + foreach($component_definition as $k => $v) + $result[$k] = $v; + } + else + $result['render'] = ''; + } + // Register component in global registry for future use + $GLOBALS['render_funcs'][$component_name] = $result; + } + return($GLOBALS['render_funcs'][$component_name]); + } + +/** + * Checks if a component exists without loading it + * Returns true if component file can be found, false otherwise + */ +function component_exists($file_name) +{ + $caller_dir = component_caller_dir(); + return(component_get_func($file_name, true, $caller_dir) !== false); +} + +/** + * Loads a component and returns its definition + * Same as component_get_func but with caller directory detection + */ +function component_load($file_name) +{ + $caller_dir = component_caller_dir(); + return(component_get_func($file_name, true, $caller_dir)); +} + +function component_call($file_name, $render_call, $prop = array()) +{ + $prop['render_call'] = $render_call; + return component($file_name, $prop); +} + +/** + * Declares an inline component directly in code (not from file) + * + * @param string $file_name - Component name for registry + * @param array $render_prop - Component definition with render functions + * + * Example: + * component_declare('my-button', [ + * 'render' => function($prop) { return ""; } + * ]); + */ +function component_declare($file_name, $render_prop) +{ + // Register the inline component directly in global registry + $GLOBALS['render_funcs'][$file_name] = $render_prop; +} + +/** + * Main component rendering function - the heart of the component system + * + * @param string $file_name - Component name or path + * @param array $prop - Properties/data to pass to component + * @return mixed - Component output from the selected render method + * + * Component calling examples: + * component('components/example/theme-switcher') + * component_call('components/workspace/panel', 'render', ['title' => 'Status']) + * component('workspace/panel', ['render_call' => 'render']) + */ +function component($file_name, $prop = array()) +{ + Profiler::log('component '.$file_name, 1); + $caller_dir = component_caller_dir(); + if($file_name == '') + return(component_error_banner('[component name empty]')); + $file_name = component_normalize_name($file_name); + + // Default render method is 'render', but can be overridden + $prop['render_call'] = first($prop['render_call'] ?? false, 'render'); + + // Support for calling specific render methods: 'component:method' + if(stristr($file_name, ':')) // you can specify which render function to call + { + $prop['render_call'] = $file_name; + $file_name = nibble(':', $prop['render_call']); + } + + // Get component definition from registry (load if not already loaded) + $renderer = $GLOBALS['render_funcs'][$file_name] ?? false; + if(!$renderer) + { + // Component not in registry - try to load from file + component_get_func($file_name, false, $caller_dir); + $renderer = $GLOBALS['render_funcs'][$file_name] ?? false; + } + + // Generate unique ID for component instance if not provided + $prop['id'] = !empty($prop['id']) ? $prop['id'] : 'c'.($GLOBALS['id_counter']++); + $prop['filename'] = $file_name; + + if(isset($prop[':before']) && $prop[':before'] !== '') + echo $prop[':before']; + + ob_start(); + if(isset($renderer[$prop['render_call']]) && is_callable($renderer[$prop['render_call']])) + $result = ($renderer[$prop['render_call']]($prop, $renderer)); + else if(isset($renderer[$prop['render_call']])) + $result = ($renderer[$prop['render_call']]); + else + $result = component_error_banner('component render method not found: '.$file_name.':'.$prop['render_call']); + $printed_result = ob_get_clean(); + + if($result === null) + $result = $printed_result; + else if(is_string($result)) + $result .= $printed_result; + else + echo $printed_result; + + if(isset($prop[':after']) && $prop[':after'] !== '') + echo $prop[':after']; + + Profiler::log(false, -1); + return($result); +} + diff --git a/web/lib/experiment.class.php b/web/lib/experiment.class.php new file mode 100644 index 0000000..c94eef4 --- /dev/null +++ b/web/lib/experiment.class.php @@ -0,0 +1,26 @@ +true,'data'=>$data] : ['ok'=>false,'errors'=>$errors]; } + private static function row(array $r): array { $r['spec'] = json_decode($r['spec'], true); if (isset($r['spec']['parameters']) && $r['spec']['parameters']===[]) $r['spec']['parameters']=(object)[]; return $r; } + private static function specJson(array $spec): string { if (isset($spec['parameters']) && $spec['parameters']===[]) $spec['parameters']=(object)[]; return json_encode($spec,JSON_THROW_ON_ERROR); } + public static function all($owner): array { + $s=LabDb::connection()->prepare('SELECT * FROM experiments WHERE owner=? ORDER BY updated_at DESC LIMIT 200'); $s->execute([$owner]); return array_map([self::class,'row'],$s->fetchAll()); + } + public static function get($id,$owner): ?array { + $s=LabDb::connection()->prepare('SELECT * FROM experiments WHERE id=? AND owner=?'); $s->execute([$id,$owner]); $r=$s->fetch(); return $r ? self::row($r) : null; + } + public static function save($owner,$data): array { + if (!is_array($data) || !is_string($data['name']??null) || trim($data['name'])==='' || !is_array($data['spec']??null)) return self::result(false,null,['experiment'=>'Name and spec are required.']); + if (!class_exists('ParameterCatalog') || !method_exists('ParameterCatalog','validate')) return self::result(false,null,['spec'=>'Parameter validation is unavailable.']); + $v=ParameterCatalog::validate($data['spec']); if (empty($v['ok'])) return self::result(false,null,$v['errors']??['spec'=>'Invalid spec.']); $data['spec']=$v['spec']; + $id=$data['id']??bin2hex(random_bytes(16)); if (!preg_match('/^[a-f0-9]{32}$/',$id)) return self::result(false,null,['id'=>'Invalid experiment ID.']); + $db=LabDb::connection(); $old=self::get($id,$owner); $now=gmdate('c'); + if ($old) { $s=$db->prepare('UPDATE experiments SET name=?,spec=?,updated_at=? WHERE id=? AND owner=?'); $s->execute([trim($data['name']),self::specJson($data['spec']),$now,$id,$owner]); } + elseif ($data['id']??false) return self::result(false,null,['id'=>'Experiment not found.']); + else { $s=$db->prepare('INSERT INTO experiments VALUES(?,?,?,?,?,?)'); $s->execute([$id,$owner,trim($data['name']),self::specJson($data['spec']),$now,$now]); } + return self::result(true,self::get($id,$owner)); + } + public static function delete($id,$owner): array { $s=LabDb::connection()->prepare('DELETE FROM experiments WHERE id=? AND owner=?'); $s->execute([$id,$owner]); return $s->rowCount() ? self::result(true,true) : self::result(false,null,['id'=>'Experiment not found.']); } +} diff --git a/web/lib/filebase.class.php b/web/lib/filebase.class.php new file mode 100644 index 0000000..59b0d7c --- /dev/null +++ b/web/lib/filebase.class.php @@ -0,0 +1,286 @@ +', 'file_put_contents_ex('.$filename.') could not write file'); + return; + } + + if (flock($fp, LOCK_EX)) + { + ftruncate($fp, 0); + rewind($fp); + $written = fwrite($fp, $data); + fflush($fp); + @chmod($filename, 0600); + flock($fp, LOCK_UN); + fclose($fp); + return $written === strlen($data); + } + else + { + Log::text('', 'file_put_contents_ex('.$filename.') could not acquire lock'); + } + + fclose($fp); + return false; + } + + static function delete_file($file_name) + { + unlink($file_name); + } + + static function read_file($file_name) + { + if(!file_exists($file_name)) + return(''); + $fsz = filesize($file_name); + if($fsz == 0) + return(''); + + $fp = fopen($file_name, "rb+"); + + if(!$fp) + return(''); + + if (flock($fp, LOCK_SH)) { + $content = fread($fp, $fsz); + flock($fp, LOCK_UN); + } else { + Log::text('', 'read_file('.$file_name.') could not acquire lock'); + } + + fclose($fp); + + return($content); + } + + static function write_data($class, $bucket, $type, $data) + { + $storage_path = self::storage_path($class, $bucket); + if(!file_exists($storage_path)) @mkdir($storage_path, 0774, true); + if(!file_exists($storage_path)) @mkdir($storage_path, 0700, true); + $json = json_encode($data); + if ($json === false) return false; + return self::write_file(self::data_filename($class, $bucket, $type), $json); + } + + static function read_data($class, $bucket, $type) + { + return(json_decode(self::read_file(self::data_filename($class, $bucket, $type)), true)); + } + + static function delete_data($class, $bucket, $type) + { + return(self::delete_file(self::data_filename($class, $bucket, $type))); + } + + static function get_data_filename($class, $bucket, $type) + { + return(self::data_filename($class, $bucket, $type)); + } + + static function list_bucket($class, $bucket) + { + $items = array(); + $storage_path = self::storage_path($class, $bucket); + if(!is_dir($storage_path)) + return($items); + foreach(scandir($storage_path) as $name) + { + if(substr($name, 0, 1) != '_' && trim($name) != '' && $name !== '.' && $name !== '..') + $items[] = $name; + } + return($items); + } + + static function search_bucket($class, $bucket, $q) + { + $items = array(); + $storage_path = self::storage_path($class, $bucket); + if(!is_dir($storage_path)) + return($items); + foreach(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($storage_path, FilesystemIterator::SKIP_DOTS)) as $file) + { + if(!$file->isFile()) + continue; + if(substr($file->getFilename(), -5) !== '.json') + continue; + $content = @file_get_contents($file->getPathname()); + if($content === false || strpos($content, $q) === false) + continue; + $name = substr($file->getPathname(), strlen($storage_path)+1, -5); + if(substr($name, 0, 1) != '_' && trim($name) != '') + $items[] = $name; + } + return($items); + } + + static function delete_directory($dir) + { + if(!is_dir($dir)) + return(''); + foreach(scandir($dir) as $entry) + { + if($entry === '.' || $entry === '..') + continue; + $path = $dir.'/'.$entry; + if(is_dir($path) && !is_link($path)) + { + $error = self::delete_directory($path); + if($error !== '') return($error); + } + else if(!@unlink($path)) + { + return('could not delete '.$path); + } + } + if(!@rmdir($dir)) + return('could not delete '.$dir); + return(''); + } + + static function delete_bucket($class, $bucket) + { + return(self::delete_directory(self::storage_path($class, $bucket))); + } + + static function write_log($class, $bucket, $type, $data) + { + $storage_path = self::storage_path($class, $bucket); + if(!file_exists($storage_path)) @mkdir($storage_path, 0774, true); + WriteToFile($storage_path.'/'.self::validate_path_part($type, 'type').'.log', json_encode($data).chr(10)); + } + + static function read_log($class, $bucket, $type, $line_count = 8, $offset = false) + { + $storage_path = self::storage_path($class, $bucket); + return(get_json_tail($storage_path.'/'.self::validate_path_part($type, 'type').'.log', $line_count, $offset)); + } + + static function line_count($class, $bucket, $type) + { + $storage_path = self::storage_path($class, $bucket); + return(intval(trim(shell_exec('wc -l '.escapeshellarg($storage_path.'/'.self::validate_path_part($type, 'type').'.log'))))); + } + + static function get_json_tail($from_file, $line_count = 8, $offset = false) + { + if($offset > 0) + { + $lines = trim(shell_exec( + 'tail -n '.escapeshellarg($offset+$line_count).' '.escapeshellarg($from_file).' | head -n '.escapeshellarg($line_count))); + } + else + { + $lines = trim(shell_exec( + 'tail -n '.escapeshellarg($line_count).' '.escapeshellarg($from_file))); + } + return(json_lines($lines)); + } + + static function get_tail($from_file, $line_count = 8, $offset = false) + { + $line_count = intval($line_count); + if($offset > 0) + { + $lines = trim(shell_exec( + 'tail -n '.escapeshellarg($offset+$line_count).' '.escapeshellarg($from_file).' | head -n '.escapeshellarg($line_count))); + } + else + { + $lines = trim(shell_exec( + 'tail -n '.escapeshellarg($line_count).' '.escapeshellarg($from_file))); + } + return(explode(chr(10), $lines)); + } + + static function get_all_lines($from_file) + { + return(explode(chr(10), trim(file_get_contents($from_file)))); + } + + static function truncate_log($from_file, $lines = 128) + { + $lines = intval($lines); + $tmp = tempnam(sys_get_temp_dir(), 'trunc_'); + shell_exec('tail -n '.$lines.' '.escapeshellarg($from_file).' > '.escapeshellarg($tmp).' && cp '.escapeshellarg($tmp).' '.escapeshellarg($from_file)); + @unlink($tmp); + return true; + } + + static function json_lines($lines) + { + if($lines == '') + { + return(array()); + } + else + { + $result = array(); + foreach(explode(chr(10), $lines) as $line) + $result[] = json_decode($line, true); + return($result); + } + } + +} diff --git a/web/lib/job.class.php b/web/lib/job.class.php new file mode 100644 index 0000000..ef8fbce --- /dev/null +++ b/web/lib/job.class.php @@ -0,0 +1,39 @@ +prepare('SELECT * FROM jobs WHERE owner=? ORDER BY created_at DESC LIMIT 100'); $s->execute([$owner]); return array_map([self::class,'row'],$s->fetchAll()); } + public static function get($id,$owner): ?array { $s=LabDb::connection()->prepare('SELECT * FROM jobs WHERE id=? AND owner=?'); $s->execute([$id,$owner]); $r=$s->fetch(); return $r?self::row($r):null; } + private static function queue($owner,$name,$experimentId,$spec,$kind='model'): array { + $captures=Maps::captures($spec);if(empty($captures['ok']))return ['ok'=>false,'errors'=>$captures['errors']??[]]; + $id=bin2hex(random_bytes(16));$cycles=$kind==='planet'?1:(int)($spec['run']['cycles']??1);$now=gmdate('c');$db=LabDb::connection(); + $db->beginTransaction();try{$q=$db->query("SELECT COUNT(*) FROM jobs WHERE status IN ('queued','running')");if((int)$q->fetchColumn()>=(int)(lab_config()['queue_limit']??100)){$db->rollBack();return ['ok'=>false,'errors'=>['queue'=>'The job queue is full.']];} + $s=$db->prepare("INSERT INTO jobs(id,experiment_id,owner,name,spec,kind,status,phase,total_cycles,created_at,updated_at) VALUES(?,?,?,?,?,?,'queued','queued',?,?,?)");$s->execute([$id,$experimentId,$owner,$name,self::specJson($spec),$kind,$cycles,$now,$now]);$insert=$db->prepare('INSERT INTO job_maps(job_id,field,version_id,digest,data) VALUES(?,?,?,?,?)');foreach($captures['data'] as $field=>$map){$insert->bindValue(1,$id);$insert->bindValue(2,$field);$insert->bindValue(3,$map['id']);$insert->bindValue(4,$map['digest']);$insert->bindValue(5,$map['data'],PDO::PARAM_LOB);$insert->execute();}$db->commit(); + }catch(Throwable $e){if($db->inTransaction())$db->rollBack();return ['ok'=>false,'error'=>'Could not queue job.'];}return ['ok'=>true,'data'=>self::get($id,$owner)]; + } + public static function enqueue($owner,$experiment): array { + if(!is_array($experiment)||empty($experiment['id']))return ['ok'=>false,'errors'=>['experiment'=>'A saved experiment is required.']];$s=LabDb::connection()->prepare('SELECT * FROM experiments WHERE id=? AND owner=?');$s->execute([$experiment['id'],$owner]);$e=$s->fetch();if(!$e)return ['ok'=>false,'errors'=>['experiment'=>'Experiment not found.']]; + $v=ParameterCatalog::validate(json_decode($e['spec'],true));if(empty($v['ok']))return ['ok'=>false,'errors'=>$v['errors']??[]];$mapCheck=Maps::validate($v['spec']);if(empty($mapCheck['ok']))return $mapCheck;return self::queue($owner,$e['name'],$e['id'],$v['spec']); + } + public static function enqueueKind($owner,$kind,$name,$spec): array { + if($kind!=='planet')return ['ok'=>false,'errors'=>['kind'=>'Unsupported job kind.']];if(!is_string($name)||trim($name)==='')return ['ok'=>false,'errors'=>['name'=>'A name is required.']];$v=ParameterCatalog::validate($spec);if(empty($v['ok']))return ['ok'=>false,'errors'=>$v['errors']??[]];if(empty($v['spec']['surface']))return ['ok'=>false,'errors'=>['surface'=>'Planet generation parameters are required.']];$mapCheck=Maps::validate($v['spec']);if(empty($mapCheck['ok']))return $mapCheck;return self::queue($owner,trim($name),null,$v['spec'],'planet'); + } + public static function cancel($id,$owner): array { $db=LabDb::connection();$s=$db->prepare("UPDATE jobs SET status=CASE WHEN status='queued' THEN 'cancelled' ELSE status END,cancel_requested=1,phase=CASE WHEN status='queued' THEN 'cancelled' ELSE phase END,finished_at=CASE WHEN status='queued' THEN ? ELSE finished_at END,updated_at=? WHERE id=? AND owner=? AND status IN ('queued','running')");$now=gmdate('c');$s->execute([$now,$now,$id,$owner]);$r=self::get($id,$owner);return $r?['ok'=>true,'data'=>$r]:['ok'=>false,'errors'=>['id'=>'Active job not found.']]; } + public static function retry($id,$owner): array { + $j=self::get($id,$owner);if(!$j||!in_array($j['status'],['failed','cancelled','interrupted'],true))return ['ok'=>false,'errors'=>['id'=>'Retry requires a stopped job.']];$newId=bin2hex(random_bytes(16));$now=gmdate('c');$db=LabDb::connection(); + $db->beginTransaction();try{$q=$db->query("SELECT COUNT(*) FROM jobs WHERE status IN ('queued','running')");if((int)$q->fetchColumn()>=(int)(lab_config()['queue_limit']??100)){$db->rollBack();return ['ok'=>false,'errors'=>['queue'=>'The job queue is full.']];}$cycles=$j['kind']==='planet'?1:(int)($j['spec']['run']['cycles']??1);$s=$db->prepare("INSERT INTO jobs(id,experiment_id,owner,name,spec,kind,status,phase,total_cycles,created_at,updated_at) VALUES(?,?,?,?,?,?,'queued','queued',?,?,?)");$s->execute([$newId,$j['experiment_id'],$owner,$j['name'],self::specJson($j['spec']),$j['kind'],$cycles,$now,$now]);$copy=$db->prepare('INSERT INTO job_maps(job_id,field,version_id,digest,data) SELECT ?,field,version_id,digest,data FROM job_maps WHERE job_id=?');$copy->execute([$newId,$j['id']]);$db->commit();}catch(Throwable $e){if($db->inTransaction())$db->rollBack();return ['ok'=>false,'error'=>'Could not queue retry.'];}return ['ok'=>true,'data'=>self::get($newId,$owner)]; + } + public static function mapCaptures(string $id): array { $q=LabDb::connection()->prepare('SELECT field,version_id,digest,data FROM job_maps WHERE job_id=? ORDER BY field');$q->execute([$id]);$maps=[];foreach($q->fetchAll() as $row)$maps[$row['field']]=['id'=>$row['version_id'],'digest'=>$row['digest'],'data'=>$row['data']];return $maps; } + public static function validatedMapCaptures(array $job): array { + $expected=$job['spec']['maps']??[];$captures=self::mapCaptures($job['id']); + $fields=array_keys($expected);sort($fields); + if(array_keys($captures)!==$fields)return ['ok'=>false,'error'=>'Stored job map captures do not match the job spec.']; + foreach($expected as $field=>$id)if(($captures[$field]['id']??null)!==$id||!hash_equals((string)($captures[$field]['digest']??''),hash('sha256',(string)($captures[$field]['data']??''))))return ['ok'=>false,'error'=>'Stored job map capture failed validation.']; + return ['ok'=>true,'data'=>$captures]; + } + public static function log($id,$owner,$offset=0): array { $j=self::get($id,$owner);if(!$j)return ['ok'=>false,'errors'=>['id'=>'Job not found.']];$path=self::directory($j).'/worker.log';$offset=max(0,(int)$offset);if(!is_file($path))return ['ok'=>true,'data'=>['text'=>'','offset'=>0,'next_offset'=>0]];$size=filesize($path);$offset=min($offset,$size);if($offset<$size-65536)$offset=$size-65536;$f=fopen($path,'rb');fseek($f,$offset);$text=fread($f,65536);$next=ftell($f);fclose($f);return ['ok'=>true,'data'=>['text'=>$text,'offset'=>$offset,'next_offset'=>$next]]; } +} diff --git a/web/lib/labdb.class.php b/web/lib/labdb.class.php new file mode 100644 index 0000000..1deea4a --- /dev/null +++ b/web/lib/labdb.class.php @@ -0,0 +1,20 @@ + PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]); + umask($oldUmask); + $pdo->exec('PRAGMA journal_mode=WAL'); + $pdo->exec('PRAGMA busy_timeout=5000'); + $pdo->exec('PRAGMA foreign_keys=ON'); + $pdo->exec((string)file_get_contents(dirname(__DIR__) . '/server/schema.sql')); + foreach ([$cfg['database'], $cfg['database'].'-wal', $cfg['database'].'-shm'] as $file) if (is_file($file)) @chmod($file, 0660); + return self::$pdo = $pdo; + } +} diff --git a/web/lib/log.class.php b/web/lib/log.class.php new file mode 100644 index 0000000..c1f1814 --- /dev/null +++ b/web/lib/log.class.php @@ -0,0 +1,41 @@ +false,'errors'=>[$field=>$message]]; } + public static function all(string $resolution): array { + $schema=MapCatalog::schema();if(!$schema||!isset($schema['grids'][$resolution]))return self::error('resolution','Unsupported map resolution.'); + $q=LabDb::connection()->prepare('SELECT id,field,resolution,name,rows,cols,digest,created_at FROM map_versions WHERE owner=? AND resolution=? ORDER BY created_at DESC LIMIT 200');$q->execute([lab_config()['workspace'],$resolution]); + return ['ok'=>true,'data'=>['maps'=>$q->fetchAll(),'fields'=>$schema['fields'],'grid'=>$schema['grids'][$resolution]]]; + } + public static function save(string $field,string $resolution,string $name,string $bytes): array { + $schema=MapCatalog::schema();$contract=$schema['fields'][$field]??null;$shape=$schema['grids'][$resolution]??null; + if(!$contract||!$shape)return self::error('field','Unsupported map field or resolution.'); + if(strlen($name)>120||trim($name)==='')return self::error('name','A map name is required (maximum 120 bytes).'); + $rows=(int)$shape['nlat'];$cols=(int)$shape['nlon'];$count=$rows*$cols;if(strlen($bytes)!==$count*4)return self::error('map','Map byte count does not match its grid.'); + $values=unpack('g*',$bytes);if(!$values||count($values)!==$count)return self::error('map','Map data is invalid.'); + foreach($values as $value)if(!is_finite($value)||$value<$contract['min']||$value>$contract['max'])return self::error('map','Map values must be finite and within the catalog range.'); + if(!empty($contract['categorical']))foreach($values as $value)if($value!==0.0&&$value!==1.0)return self::error('map','Categorical map values must be 0 or 1.'); + $id=bin2hex(random_bytes(16));$digest=hash('sha256',$bytes);$db=LabDb::connection();$q=$db->prepare('INSERT INTO map_versions(id,owner,field,resolution,name,rows,cols,digest,data,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)'); + $q->bindValue(1,$id);$q->bindValue(2,lab_config()['workspace']);$q->bindValue(3,$field);$q->bindValue(4,$resolution);$q->bindValue(5,trim($name));$q->bindValue(6,$rows,PDO::PARAM_INT);$q->bindValue(7,$cols,PDO::PARAM_INT);$q->bindValue(8,$digest);$q->bindValue(9,$bytes,PDO::PARAM_LOB);$q->bindValue(10,gmdate('c'));$q->execute(); + return ['ok'=>true,'data'=>['id'=>$id,'field'=>$field,'resolution'=>$resolution,'name'=>trim($name),'shape'=>[$rows,$cols],'digest'=>$digest]]; + } + public static function get(string $id): ?array { if(!preg_match('/^[a-f0-9]{32}$/',$id))return null;$q=LabDb::connection()->prepare('SELECT id,field,resolution,name,rows,cols,digest,data,created_at FROM map_versions WHERE id=? AND owner=?');$q->execute([$id,lab_config()['workspace']]);return $q->fetch()?:null; } + public static function validate(array $spec): array { + $maps=$spec['maps']??[];if(!is_array($maps)||(array_is_list($maps)&&$maps!==[]))return self::error('maps','Expected a map version object.');$schema=MapCatalog::schema(); + foreach(array_diff(array_keys($maps),array_keys($schema['fields']??[])) as $key)return self::error('maps.'.$key,'Unsupported map field.');$resolution=$spec['model']['resolution']??''; + foreach($maps as $field=>$id){if(!is_string($id)||!($map=self::get($id)))return self::error('maps.'.$field,'Map version not found.');if($map['field']!==$field||$map['resolution']!==$resolution)return self::error('maps.'.$field,'Map field or resolution does not match the job.');if(!hash_equals($map['digest'],hash('sha256',$map['data'])))return self::error('maps.'.$field,'Stored map digest does not match.');} + return ['ok'=>true,'data'=>true]; + } + public static function captures(array $spec): array { + $valid=self::validate($spec);if(empty($valid['ok']))return $valid;$captures=[]; + foreach($spec['maps']??[] as $field=>$id){$map=self::get($id);$captures[$field]=['id'=>$id,'digest'=>$map['digest'],'data'=>$map['data']];} + return ['ok'=>true,'data'=>$captures]; + } + public static function writeSnapshots(array $captures,string $jobDir): array { + if(!$captures)return ['ok'=>true,'data'=>[]];$target=$jobDir.'/inputs/maps';if(!is_dir($target)&&!mkdir($target,0750,true)&&!is_dir($target))return self::error('maps','Could not create job map snapshot.');$paths=[]; + foreach($captures as $field=>$capture){$bytes=$capture['data']??'';if(!is_string($bytes)||!hash_equals((string)($capture['digest']??''),hash('sha256',$bytes)))return self::error('maps.'.$field,'Captured map digest does not match.');$path=$target.'/'.$field.'.f32';if(file_put_contents($path,$bytes,LOCK_EX)!==strlen($bytes))return self::error('maps.'.$field,'Could not write map snapshot.');$paths[$field]=$path;} + return ['ok'=>true,'data'=>$paths]; + } +} diff --git a/web/lib/parametercatalog.class.php b/web/lib/parametercatalog.class.php new file mode 100644 index 0000000..9226f73 --- /dev/null +++ b/web/lib/parametercatalog.class.php @@ -0,0 +1,50 @@ +$rule['max']))return[$value,'The value is outside the allowed range.'];} + if(isset($rule['choices'])&&!in_array($value,$rule['choices'],true))return[$value,'Select an allowed value.']; + if(is_string($value)&&(mb_strlen($value)>200||(isset($rule['pattern'])&&!preg_match('~^(?:'.$rule['pattern'].')$~D',$value))))return[$value,'The value has an invalid format.']; + if(is_array($value)&&!array_is_list($value)){ + $properties=$rule['properties']??[];$unknown=array_diff(array_keys($value),array_keys($properties));if($unknown){sort($unknown);return[$value,'Unknown keys: '.implode(', ',$unknown).'.'];} + $result=is_array($rule['default']??null)?$rule['default']:[]; + foreach($value as $key=>$item){if(!isset($properties[$key]))continue;[$normalized,$error]=self::value($item,$properties[$key]);if($error)return[$value,$key.': '.$error];$result[$key]=$normalized;} + $missing=array_diff($rule['required']??[],array_keys($result));if($missing){sort($missing);return[$value,'Required keys: '.implode(', ',$missing).'.'];}$value=$result; + } + return[$value,null]; + } + public static function validate($spec): array { + $schema=self::schema();if(!$schema)return['ok'=>false,'spec'=>$spec,'errors'=>['schema'=>'Parameter catalog is unavailable.']]; + if(!is_array($spec)|| (array_is_list($spec)&&$spec!==[]))return['ok'=>false,'spec'=>$spec,'errors'=>['spec'=>'Expected an object.']]; + $errors=[];$normalized=[]; + foreach(array_diff(array_keys($spec),['model','run','parameters','surface','maps']) as $key)$errors[$key]='Unknown spec section.'; + foreach(['model','run'] as $section){$values=array_key_exists($section,$spec)?$spec[$section]:[];if(!is_array($values)||(array_is_list($values)&&$values!==[])){$errors[$section]='Expected an object.';continue;} + $normalized[$section]=$section==='model'?($schema['model_defaults']):$schema['run_defaults']; + foreach($values as $key=>$value){if(!isset($schema[$section][$key])){$errors[$section.'.'.$key]='Unknown field.';continue;}[$result,$error]=self::value($value,$schema[$section][$key]);if($error)$errors[$section.'.'.$key]=$error;else $normalized[$section][$key]=$result;} + } + $parameters=array_key_exists('parameters',$spec)?$spec['parameters']:[];if($parameters===[])$parameters=[];$normalized['parameters']=[]; + if(!is_array($parameters)||(array_is_list($parameters)&&$parameters!==[]))$errors['parameters']='Expected an object.'; + else {$fields=[];foreach($schema['fields'] as $field)$fields[$field['name']]=$field; + foreach($parameters as $name=>$value){$field=$fields[$name]??null;if(!$field){$errors['parameters.'.$name]='Unknown parameter.';continue;} + if(empty($field['editable'])){if($value!==($field['default']??null))$errors['parameters.'.$name]='This input is managed by the application.';continue;} + $rule=$field;if($name==='ozone')$rule['types']=['boolean','object']; + if($name==='vegetation'&&($value===true||$value===false)){$normalized['parameters'][$name]=$value;continue;} + [$result,$error]=self::value($value,$rule);if($error)$errors['parameters.'.$name]=$error;else $normalized['parameters'][$name]=$result; + } + } + if(array_key_exists('maps',$spec)){$maps=$spec['maps'];$rules=$schema['maps']['fields']??[];if(!is_array($maps)||(array_is_list($maps)&&$maps!==[]))$errors['maps']='Expected a map version object.';else{$normalized['maps']=[];foreach($maps as $field=>$id){if(!isset($rules[$field])){$errors['maps.'.$field]='Unsupported map field.';continue;}if(!is_string($id)||!preg_match('/^[a-f0-9]{32}$/',$id))$errors['maps.'.$field]='Expected a saved map version ID.';else $normalized['maps'][$field]=$id;}}} + if(array_key_exists('surface',$spec)){$surface=$spec['surface'];$rules=$schema['surface']??[];if(!is_array($surface)||(array_is_list($surface)&&$surface!==[]))$errors['surface']='Expected an object.';else{$normalized['surface']=[];foreach(array_diff(array_keys($surface),array_keys($rules)) as $key)$errors['surface.'.$key]='Unknown field.';foreach($rules as $key=>$rule){$item=array_key_exists($key,$surface)?$surface[$key]:($rule['default']??null);[$result,$error]=self::value($item,$rule);if($error)$errors['surface.'.$key]=$error;else $normalized['surface'][$key]=$result;}}} + return['ok'=>!$errors,'spec'=>$normalized,'errors'=>$errors]; + } +} diff --git a/web/lib/profiler.class.php b/web/lib/profiler.class.php new file mode 100644 index 0000000..f4f1937 --- /dev/null +++ b/web/lib/profiler.class.php @@ -0,0 +1,42 @@ + 0) // if greater than zero, update indent after logging + self::$indent_str = str_repeat(' ', self::$indent_level * 2); + self::$last = $thistime; + self::$current = $absoluteMS; + return($thistime); + } + + static function get_time() + { + return(1000*(microtime(true) - self::$start)); + } + + static function start() + { + self::$start = self::$last = microtime(true); + } + +} diff --git a/web/lib/results.class.php b/web/lib/results.class.php new file mode 100644 index 0000000..2e9162a --- /dev/null +++ b/web/lib/results.class.php @@ -0,0 +1,14 @@ +'.safe($label).''; +} + +function clamp($v, $min, $max) +{ + if($v < $min) return $min; + if($v > $max) return $max; + return $v; +} + +function pick_entry_from_range($array, $value) +{ + if(!is_array($array)) return []; + $result = []; + foreach($array as $pv) + if($value >= $pv['from'] && $value <= $pv['to']) $result = $pv; + return $result; +} + +/** + * Can have any number of arguments. Returns the first of its arguments that is not false, empty string, or null. + */ +function first() +{ + $args = func_get_args(); + foreach($args as $v) + { + if(isset($v) && $v !== false && $v !== '' && $v !== null) + return($v); + } + return(''); +} + +function alnum($s, $replace_with = '_', $trim = true) +{ + if($trim) $s = trim(strtolower($s)); + return(preg_replace("/[^[:alnum:][:space:]]/u", $replace_with, $s)); +} + +/** + * Append a string to the given file. + */ +function write_to_file($filename, $content) +{ + if (is_array($content)) $content = json_encode($content); + $open = fopen($filename, 'a+'); + fwrite($open, $content); + fclose($open); + @chmod($filename, 0777); +} + +# **************************** ARRAY FUNCTIONS ****************************** + +/** + * Returns a value from the $GLOBALS['config'] array identified by $key. + * Sub-array values can be addressed by using the '/' character as a separator. + */ +function cfg($key) +{ + $config = $GLOBALS['config']; + $seg = explode('/', $key); + $lastSeg = array_pop($seg); + foreach($seg as $s) + { + if(is_array($config) && array_key_exists($s, $config) && is_array($config[$s])) + $config = $config[$s]; + else + return null; + } + if(!is_array($config) || !array_key_exists($lastSeg, $config)) + return null; + return($config[$lastSeg]); +} + + +# **************************** STRING/FORMATTING FUNCTIONS ****************************** + +/** + * Convert any base number into another number of another base system. + */ +function base_convert_any($numberInput, $fromBaseInput, $toBaseInput) +{ + if ($fromBaseInput==$toBaseInput) return $numberInput; + $fromBase = str_split($fromBaseInput,1); + $toBase = str_split($toBaseInput,1); + $number = str_split($numberInput,1); + $fromLen=strlen($fromBaseInput); + $toLen=strlen($toBaseInput); + $numberLen=strlen($numberInput); + $retval=''; + if ($toBaseInput == '0123456789') + { + $retval=0; + for ($i = 1;$i <= $numberLen; $i++) + $retval = bcadd($retval, bcmul(array_search($number[$i-1], $fromBase),bcpow($fromLen,$numberLen-$i))); + return $retval; + } + if ($fromBaseInput != '0123456789') + $base10=base_convert_any($numberInput, $fromBaseInput, '0123456789'); + else + $base10 = $numberInput; + if ($base10 $page] + $params; + $query = http_build_query($params); + return cfg('url/root') . ($query !== '' ? '?' . $query : ''); + } +} diff --git a/web/server/schema.sql b/web/server/schema.sql new file mode 100644 index 0000000..2231a5f --- /dev/null +++ b/web/server/schema.sql @@ -0,0 +1,28 @@ +PRAGMA foreign_keys=ON; +CREATE TABLE IF NOT EXISTS experiments ( + id TEXT PRIMARY KEY CHECK(length(id)=32), owner TEXT NOT NULL, name TEXT NOT NULL, + spec TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS experiments_owner_updated ON experiments(owner, updated_at DESC); +CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY CHECK(length(id)=32), experiment_id TEXT, owner TEXT NOT NULL, + name TEXT NOT NULL, spec TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'model' CHECK(kind IN ('model','planet')), status TEXT NOT NULL CHECK(status IN ('queued','running','completed','failed','cancelled','interrupted')), + phase TEXT NOT NULL DEFAULT 'queued', progress REAL NOT NULL DEFAULT 0, + completed_cycles INTEGER NOT NULL DEFAULT 0, total_cycles INTEGER NOT NULL, + created_at TEXT NOT NULL, started_at TEXT, finished_at TEXT, updated_at TEXT NOT NULL, + error TEXT, cancel_requested INTEGER NOT NULL DEFAULT 0, pid INTEGER, log_offset INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS map_versions ( + id TEXT PRIMARY KEY CHECK(length(id)=32), owner TEXT NOT NULL, field TEXT NOT NULL, + resolution TEXT NOT NULL, name TEXT NOT NULL, rows INTEGER NOT NULL, cols INTEGER NOT NULL, + digest TEXT NOT NULL CHECK(length(digest)=64), data BLOB NOT NULL, created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS map_versions_owner_resolution ON map_versions(owner,resolution,created_at DESC); +CREATE TABLE IF NOT EXISTS job_maps ( + job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, field TEXT NOT NULL, + version_id TEXT NOT NULL, digest TEXT NOT NULL CHECK(length(digest)=64), data BLOB NOT NULL, + PRIMARY KEY(job_id,field) +); +CREATE INDEX IF NOT EXISTS jobs_owner_created ON jobs(owner, created_at DESC); +CREATE INDEX IF NOT EXISTS jobs_state_created ON jobs(status, created_at); +CREATE INDEX IF NOT EXISTS jobs_owner_state ON jobs(owner, status, created_at DESC); diff --git a/web/server/worker.php b/web/server/worker.php new file mode 100644 index 0000000..b9340d5 --- /dev/null +++ b/web/server/worker.php @@ -0,0 +1,178 @@ +config = $config ?? lab_config(); + $this->db = LabDb::connection(); + } + + public function acquire(): bool { + $path = rtrim($this->config['jobs'], '/') . '/.worker.lock'; + if (!is_dir(dirname($path)) && !mkdir(dirname($path), 0750, true) && !is_dir(dirname($path))) return false; + $this->lock = fopen($path, 'c'); + return $this->lock !== false && flock($this->lock, LOCK_EX | LOCK_NB); + } + + public function recover(): void { + $now = gmdate('c'); + $this->db->exec("UPDATE jobs SET status='interrupted',phase='interrupted',error=COALESCE(error,'Worker stopped before job completion'),finished_at=" . $this->db->quote($now) . ",updated_at=" . $this->db->quote($now) . ',pid=NULL WHERE status=\'running\''); + } + + public function claim(): ?array { + $path = $this->config['runtime_lock']; + $runtime = fopen($path, is_file($path) ? 'r' : 'c'); + if ($runtime === false) return null; + if (!flock($runtime, LOCK_SH | LOCK_NB)) { fclose($runtime); return null; } + try { + $this->db->exec('BEGIN IMMEDIATE'); + if ($this->db->query("SELECT 1 FROM jobs WHERE status='running' LIMIT 1")->fetchColumn()) { $this->db->commit(); return null; } + $row = $this->db->query("SELECT * FROM jobs WHERE status='queued' ORDER BY created_at LIMIT 1")->fetch(); + if (!$row) { $this->db->commit(); return null; } + $now = gmdate('c'); + $s = $this->db->prepare("UPDATE jobs SET status='running',phase='starting',started_at=?,updated_at=? WHERE id=? AND status='queued'"); + $s->execute([$now, $now, $row['id']]); + if (!$s->rowCount()) { $this->db->commit(); return null; } + $this->db->commit(); + try { $row['spec'] = json_decode($row['spec'], true, 512, JSON_THROW_ON_ERROR); } + catch (JsonException $e) { $row['spec_error'] = 'Stored job spec is not valid JSON.'; $row['spec'] = null; } + return $row; + } catch (Throwable $e) { if ($this->db->inTransaction()) $this->db->rollBack(); throw $e; } + finally { fclose($runtime); } + } + + public function run(array $job): void { + if (!empty($job['spec_error'])) { $this->finish($job['id'],'failed',$job['spec_error']); return; } + if (!in_array($job['kind'] ?? '', ['model','planet'],true)) { $this->finish($job['id'],'failed','Unsupported job kind.'); return; } + $validation = ParameterCatalog::validate($job['spec']); + if (empty($validation['ok'])) { $this->finish($job['id'], 'failed', 'Stored job spec failed validation: ' . json_encode($validation['errors'] ?? [])); return; } + $job['spec'] = $validation['spec']; + $dir = Job::directory(['id' => $job['id']]); + if (!is_dir($dir) && !mkdir($dir, 0750, true) && !is_dir($dir)) { $this->finish($job['id'], 'failed', 'Could not create the job directory.'); return; } + $jobFile = $dir . '/job.json'; + $captured=Job::validatedMapCaptures($job); + if(empty($captured['ok'])){$this->finish($job['id'],'failed',$captured['error']);return;} + $mapSnapshot=Maps::writeSnapshots($captured['data'],$dir); + if(empty($mapSnapshot['ok'])){$this->finish($job['id'],'failed','Map snapshot is unavailable: '.json_encode($mapSnapshot['errors']??[]));return;} + $scientificSpec = $job['spec']; + $scientificSpec['parameters'] = (object)$scientificSpec['parameters']; + $this->atomicJson($jobFile, ['id'=>$job['id'], 'name'=>$job['name'], 'kind'=>$job['kind'], 'spec'=>$scientificSpec, 'directory'=>$dir, 'map_snapshots'=>$mapSnapshot['data']]); + $configFile = getenv('EXOPLASIM_CONFIG') ?: $this->config['project_root'] . '/config/web.json'; + $configFile = realpath($configFile); + if ($configFile === false) { $this->finish($job['id'],'failed','Configuration file does not exist.'); return; } + $scriptRoot = $this->config['science_scripts'] ?? ($this->config['project_root'] . '/scripts/web'); + $script = $scriptRoot . ($job['kind'] === 'planet' ? '/planet.py' : '/model.py'); + $command = ['setsid', $this->config['python'], $script, '--config', $configFile, '--job', $jobFile]; + if (!is_file($script)) { $this->finish($job['id'],'failed','Science task script does not exist.'); return; } + if (!is_file($this->config['python']) || !is_executable($this->config['python'])) { $this->finish($job['id'],'failed','Configured Python executable is unavailable.'); return; } + $logPath = $dir . '/worker.log'; $this->activeLog = $logPath; + $log = fopen($logPath, 'ab'); + if (!$log) { $this->finish($job['id'], 'failed', 'Could not open the job log.'); return; } + if (disk_free_space($dir) < (int)$this->config['minimum_free_bytes']) { fclose($log); $this->finish($job['id'], 'failed', 'Insufficient free disk space.'); return; } + $pipes = []; + $environment=getenv(); if(!is_array($environment))$environment=[]; $environment['EXOPLASIM_CONFIG']=$configFile; + $proc = proc_open($command, [0=>['pipe','r'], 1=>['pipe','w'], 2=>['file',$logPath,'a']], $pipes, $this->config['project_root'], $environment); + fclose($log); + if (!is_resource($proc)) { $this->finish($job['id'], 'failed', 'Could not start the science task.'); return; } + fclose($pipes[0]); stream_set_blocking($pipes[1], false); + $status = proc_get_status($proc); $pid = (int)$status['pid']; + $this->update($job['id'], ['pid'=>$pid,'phase'=>'running']); + $started = microtime(true); $buffer = ''; $lastCycle = 0; $datasetCount = 0; $error = null; $cancelled = false; $timedOut = false; $terminationAt = null; $exitCode = null; + while (true) { + $status = proc_get_status($proc); + $chunk = stream_get_contents($pipes[1]); + if ($chunk !== false && $chunk !== '') { + $buffer .= $chunk; + try { $this->events($job, $buffer, $lastCycle, $datasetCount, $error); } + catch (Throwable $e) { $error=$e->getMessage(); if($terminationAt===null){$terminationAt=microtime(true);$this->signalGroup($pid,SIGTERM);} } + } + $current = $this->db->prepare('SELECT cancel_requested FROM jobs WHERE id=?'); $current->execute([$job['id']]); + if ($error !== null && $terminationAt === null) { $terminationAt=microtime(true); $this->signalGroup($pid,SIGTERM); } + if ((int)$current->fetchColumn() && !$cancelled) { $cancelled = true; $terminationAt = microtime(true); $this->signalGroup($pid, SIGTERM); } + if (microtime(true) - $started > (int)$this->config['job_timeout_seconds'] && !$timedOut) { $timedOut = true; $terminationAt = microtime(true); $this->signalGroup($pid, SIGTERM); } + if (disk_free_space($dir) < (int)$this->config['minimum_free_bytes'] && $error === null) { $error = 'Disk space fell below the configured floor.'; $terminationAt = microtime(true); $this->signalGroup($pid, SIGTERM); } + if (!$status['running']) { $exitCode = $status['exitcode']; break; } + if ($terminationAt !== null && microtime(true)-$terminationAt > 5) $this->signalGroup($pid, SIGKILL); + usleep(200000); + } + $tail = stream_get_contents($pipes[1]); if ($tail !== false) { $buffer .= $tail; try { $this->events($job, $buffer, $lastCycle, $datasetCount, $error); } catch (Throwable $e) { $error=$e->getMessage(); } } + if ($buffer !== '') file_put_contents($logPath,$buffer,FILE_APPEND); + fclose($pipes[1]); $closedExit = proc_close($proc); $exit = $exitCode !== null && $exitCode >= 0 ? $exitCode : $closedExit; + if ($cancelled) $this->finish($job['id'],'cancelled',null,$lastCycle); + elseif ($timedOut) $this->finish($job['id'],'failed','Job exceeded its time limit.',$lastCycle); + elseif ($error !== null) $this->finish($job['id'],'failed',$error,$lastCycle); + elseif ($exit !== 0) $this->finish($job['id'],'failed',$this->tail($logPath),$lastCycle); + elseif ($lastCycle < ($job['kind']==='planet'?1:(int)$job['total_cycles']) || $datasetCount < ($job['kind']==='planet'?1:(int)$job['total_cycles'])) $this->finish($job['id'],'failed','Science task exited without publishing all expected cycle results.',$lastCycle); + else $this->finish($job['id'],'completed',null,$job['kind']==='planet'?1:(int)$job['total_cycles']); + } + + private function events(array $job, string &$buffer, int &$lastCycle, int &$datasetCount, ?string &$error): void { + while (($at = strpos($buffer, "\n")) !== false) { + $line = substr($buffer, 0, $at); $buffer = substr($buffer, $at + 1); + file_put_contents($this->activeLog,$line."\n",FILE_APPEND); + $event = json_decode($line, true); if (!is_array($event)) continue; + if (isset($event['error'])) $error = (string)$event['error']; + if (isset($event['cycle'])) { + $lastCycle = max($lastCycle, (int)$event['cycle']); + $total = max(1, (int)($event['cycles'] ?? $job['total_cycles'])); + $this->update($job['id'], ['completed_cycles'=>$lastCycle,'progress'=>100.0*$lastCycle/$total,'phase'=>isset($event['dataset'])?'postprocessing':'running']); + if (isset($event['dataset'])) { + if (!empty($event['planet'])) $this->publishPlanet($job,$event); + else $this->publishIndex($job, $event); + $datasetCount++; + } + } + if (($event['planet'] ?? false) && isset($event['spec'])) $this->saveGeneratedExperiment($job, $event['spec']); + } + } + + private function publishPlanet(array $job,array $event): void { + if(empty($event['dataset'])||!preg_match('/^[a-f0-9]{32}$/',$event['dataset'])) throw new RuntimeException('Planet task returned an invalid dataset ID.'); + $base=Job::directory(['id'=>$job['id']]).'/artifacts';$path=$base.'/datasets/'.$event['dataset'].'/index.json'; + $index=json_decode((string)@file_get_contents($path),true); + if(!is_array($index)||($index['id']??null)!==$event['dataset']) throw new RuntimeException('Planet dataset index is missing or does not match its ID.'); + $this->atomicJson($base.'/index.json',['datasets'=>[['id'=>$event['dataset'],'label'=>'Generated planet surface']],'latest'=>$event['dataset']]); + } + + private function saveGeneratedExperiment(array $job, array $spec): void { + $valid = ParameterCatalog::validate($spec); + if (empty($valid['ok'])) throw new RuntimeException('Generated experiment failed validation: ' . json_encode($valid['errors'] ?? [])); + $saved = Experiment::save($job['owner'], ['name'=>$job['name'],'spec'=>$valid['spec']]); + if (empty($saved['ok'])) throw new RuntimeException('Could not save generated experiment.'); + $s=$this->db->prepare('UPDATE jobs SET experiment_id=? WHERE id=?'); $s->execute([$saved['data']['id'],$job['id']]); + } + + private function publishIndex(array $job, array $event): void { + if (empty($event['dataset']) || !preg_match('/^[a-f0-9]{32}$/',$event['dataset'])) return; + $root=Job::directory(['id'=>$job['id']]) . '/artifacts'; + $datasetIndex=$root.'/datasets/'.$event['dataset'].'/index.json'; + $dataset=json_decode((string)@file_get_contents($datasetIndex),true); + if (!is_array($dataset)||($dataset['id']??null)!==$event['dataset']) throw new RuntimeException('Science task dataset index is missing or invalid.'); + $path=$root.'/index.json'; + $index=is_file($path)?json_decode((string)file_get_contents($path),true):['datasets'=>[],'latest'=>null]; + $index['datasets'][]=['id'=>$event['dataset'],'label'=>'Cycle '.(int)($event['cycle']??1)]; $index['latest']=$event['dataset']; + $this->atomicJson($path,$index); + } + + private function signalGroup(int $pid, int $signal): void { if (function_exists('posix_kill')) @posix_kill(-$pid,$signal); } + private function update(string $id, array $values): void { $sets=[]; $args=[]; foreach($values as $key=>$value){$sets[]=$key.'=?';$args[]=$value;} $sets[]='updated_at=?';$args[]=gmdate('c');$args[]=$id;$s=$this->db->prepare('UPDATE jobs SET '.implode(',',$sets).' WHERE id=?');$s->execute($args); } + private function finish(string $id,string $status,?string $error,?int $cycles=null): void { $s=$this->db->prepare('UPDATE jobs SET status=?,phase=?,progress=CASE WHEN ?=\'completed\' THEN 100 ELSE progress END,completed_cycles=COALESCE(?,completed_cycles),error=?,pid=NULL,finished_at=?,updated_at=? WHERE id=?');$s->execute([$status,$status,$status,$cycles,$error,gmdate('c'),gmdate('c'),$id]); } + private function tail(string $path): string { $file=@fopen($path,'rb');if(!$file)return 'Science task failed.';$size=fstat($file)['size']??0;fseek($file,max(0,$size-8192));$data=fread($file,8192);fclose($file);return $data===false?'Science task failed.':$data; } + private function atomicJson(string $path,array $data): void { $tmp=$path.'.'.bin2hex(random_bytes(4)).'.tmp';file_put_contents($tmp,json_encode($data,JSON_THROW_ON_ERROR));rename($tmp,$path); } + + public function loop(): void { + if (!$this->acquire()) return; + $this->recover(); + while (true) { $job=$this->claim(); if (!$job) { usleep((int)($this->config['poll_seconds']??2)*1000000); continue; } try { $this->run($job); } catch (Throwable $e) { $this->finish($job['id'],'failed',$e->getMessage()); } } + } +} + +if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) (new LabWorker())->loop(); diff --git a/web/tests/bootstrap.php b/web/tests/bootstrap.php new file mode 100644 index 0000000..9c55ec0 --- /dev/null +++ b/web/tests/bootstrap.php @@ -0,0 +1,18 @@ +callable fixture function($prop) { + return 'custom fixture '.safe($prop['id']); + }, +]; diff --git a/web/tests/exoplasim-workbench-smoke.php b/web/tests/exoplasim-workbench-smoke.php new file mode 100644 index 0000000..f6a203b --- /dev/null +++ b/web/tests/exoplasim-workbench-smoke.php @@ -0,0 +1,18 @@ +&1', $output, $status); + if($status !== 0) { fwrite(STDERR, implode("\n", $output)."\n"); exit($status); } + $output = []; +} +$js = file_get_contents(__DIR__.'/../js/workbench.js'); +foreach(['window.EXOPLASIM','save-run','setDataset','cancel','retry','offset'] as $token) { + if(strpos($js, $token) === false) { fwrite(STDERR, "Missing workbench behavior: {$token}\n"); exit(1); } +} +echo "ExoPlaSim workbench smoke checks passed.\n"; diff --git a/web/tests/filebase-path-guard.php b/web/tests/filebase-path-guard.php new file mode 100644 index 0000000..30ebc5f --- /dev/null +++ b/web/tests/filebase-path-guard.php @@ -0,0 +1,52 @@ + 'safe needle')); +$filename = Filebase::get_data_filename('docs', 'bucket-one/nested', 'meta'); +test_assert(str_starts_with($filename, $root.'docs/'), 'data filename stays inside configured filebase root'); +test_assert(Filebase::read_data('docs', 'bucket-one/nested', 'meta')['title'] === 'safe needle', 'nested bucket data round-trips'); +test_assert(Filebase::list_bucket('docs', 'bucket-one/nested') === array('meta.json'), 'list_bucket uses PHP directory listing'); +test_assert(Filebase::search_bucket('docs', 'bucket-one/nested', 'needle') === array('meta'), 'search_bucket finds matching JSON without shell grep'); + +test_throws(function() { Filebase::get_data_filename('docs', '../escape', 'meta'); }, 'bucket path traversal is rejected'); +test_throws(function() { Filebase::get_data_filename('../docs', 'bucket', 'meta'); }, 'class path traversal is rejected'); +test_throws(function() { Filebase::get_data_filename('docs', 'bucket', '../meta'); }, 'type path traversal is rejected'); + +$escape_dir = $root.'escape-marker'; +@mkdir($escape_dir, 0774, true); +file_put_contents($escape_dir.'/keep.txt', 'keep'); +Filebase::delete_bucket('docs', 'bucket-one/nested'); +test_assert(!file_exists($root.'docs/ne/bucket-one/nested'), 'delete_bucket removes the selected bucket'); +test_assert(file_exists($escape_dir.'/keep.txt'), 'delete_bucket does not remove sibling paths outside the bucket'); + +Filebase::delete_directory($root); +echo "All Filebase path guard tests passed.\n"; diff --git a/web/tests/smoke.php b/web/tests/smoke.php new file mode 100644 index 0000000..41b54fe --- /dev/null +++ b/web/tests/smoke.php @@ -0,0 +1,37 @@ +') === '<script>', 'safe escapes HTML text'); +test_assert(asafe('" onload="x') === '" onload="x', 'asafe escapes HTML attributes'); + +$head = component_call('theme/shell', 'head', [ + 'extra_css' => ['themes/exoplasim/css/style.css'], + 'extra_js' => ['js/workbench.js'], +]); +test_assert(str_contains($head, 'themes/exoplasim/css/style.css'), 'theme shell includes the application stylesheet'); +test_assert(str_contains($head, 'js/workbench.js'), 'theme shell includes the workbench script'); +test_assert(!str_contains($head, 'favicon') && !str_contains($head, 'icon.png'), 'theme shell does not reference removed starter icons'); + +echo "All smoke tests passed.\n"; diff --git a/web/themes/common/page.blank.php b/web/themes/common/page.blank.php new file mode 100644 index 0000000..92858e3 --- /dev/null +++ b/web/themes/common/page.blank.php @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/themes/common/page.json.php b/web/themes/common/page.json.php new file mode 100644 index 0000000..89fe5e2 --- /dev/null +++ b/web/themes/common/page.json.php @@ -0,0 +1,12 @@ +div{display:grid;grid-template-columns:5rem minmax(2rem,1fr) auto;align-items:center;gap:.5rem}.viz-rgb-legend i{display:block;height:.7rem}@media(max-width:600px){.viz-view{min-height:0}.viz-footer{grid-template-columns:1fr}.viz-chart{grid-column:1}} diff --git a/web/themes/exoplasim/page.html.php b/web/themes/exoplasim/page.html.php new file mode 100644 index 0000000..c50c05a --- /dev/null +++ b/web/themes/exoplasim/page.html.php @@ -0,0 +1,12 @@ + + + + + ['themes/exoplasim/css/style.css', 'themes/exoplasim/css/visualization.css', 'themes/exoplasim/css/map-editor.css'], 'extra_js' => ['js/composite.js', 'js/visualization.js', 'js/map-editor.js', 'js/workbench.js'], 'description' => 'ExoPlaSim model workbench']) ?> + + +
    + + diff --git a/web/views/api/index.php b/web/views/api/index.php new file mode 100644 index 0000000..bd53609 --- /dev/null +++ b/web/views/api/index.php @@ -0,0 +1,129 @@ +true,'data'=>$data]:array_filter(['ok'=>false,'error'=>is_string($errors)?$errors:'Request failed.','errors'=>is_array($errors)?$errors:null]),JSON_INVALID_UTF8_SUBSTITUTE); } +$config=lab_config(); +$owner=$config['workspace']??''; +if (!is_string($owner)||$owner==='') { api_reply(false,null,['workspace'=>'Workspace is not configured.'],503); exit; } +$method=$_SERVER['REQUEST_METHOD']??'GET'; $action=is_string($_GET['api']??null)?$_GET['api']:''; +$input=$_POST; if ($method==='POST' && str_contains($_SERVER['CONTENT_TYPE']??'','application/json')) { $decoded=json_decode((string)file_get_contents('php://input'),true); if (is_array($decoded)) $input=$decoded; } +$id=is_string($_GET['id']??null)?$_GET['id']:''; $result=null; +switch($action) { +case 'map': + if($method!=='GET'){api_reply(false,null,['method'=>'GET required.'],405);exit;} + $map=Maps::get($id);if(!$map){api_reply(false,null,['id'=>'Map version not found.'],404);exit;} + $catalog=MapCatalog::schema();$metadata=['id'=>$map['id'],'field'=>$map['field'],'resolution'=>$map['resolution'],'name'=>$map['name'],'digest'=>$map['digest'],'field_metadata'=>$catalog['fields'][$map['field']]]; + header('Content-Type: application/octet-stream');header('X-Map-Shape: '.$map['rows'].','.$map['cols']);header('X-Map-Metadata: '.base64_encode(json_encode($metadata)));echo $map['data'];exit; +case 'maps': + if($method==='GET'){$result=Maps::all((string)($_GET['resolution']??''));if(!empty($result['ok'])){$result['data']['fields']=ParameterCatalog::schema()['map_fields']??$result['data']['fields'];$result['data']['grid']=ParameterCatalog::schema()['map_grids'][$_GET['resolution']]??$result['data']['grid'];}api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?422:200);exit;} + if($method!=='POST'){api_reply(false,null,['method'=>'GET or POST required.'],405);exit;} + if(!str_starts_with($_SERVER['CONTENT_TYPE']??'','application/octet-stream')){api_reply(false,null,['content_type'=>'Use application/octet-stream.'],415);exit;} + $result=Maps::save((string)($_GET['field']??''),(string)($_GET['resolution']??''),(string)($_GET['name']??''),(string)file_get_contents('php://input'));api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?422:201);exit; +case 'schema': $result=ParameterCatalog::schema(); if(!$result) api_reply(false,null,['schema'=>'Parameter catalog unavailable.'],503); else api_reply(true,$result); exit; +case 'experiments': if($method==='GET') api_reply(true,Experiment::all($owner)); else { $result=Experiment::save($owner,$input); api_reply($result['ok'],$result['data']??null,$result['errors']??$result['error']??null,empty($result['ok'])?422:200); } exit; +case 'experiment_delete': $result=Experiment::delete($input['id']??'',$owner); api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?404:200); exit; +case 'jobs': if($method==='GET') api_reply(true,Job::all($owner)); else { $e=Experiment::get($input['experiment_id']??'',$owner); if(!$e) api_reply(false,null,['experiment'=>'Experiment not found.'],404); else { $result=Job::enqueue($owner,$e); api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?422:200); } } exit; +case 'generate': $spec=$input['spec']??null; $result=Job::enqueueKind($owner,'planet',$input['name']??'Generated planet',$spec); api_reply($result['ok'],isset($result['data'])?['job'=>$result['data']]:null,$result['errors']??$result['error']??null,empty($result['ok'])?422:200); exit; +case 'cancel': $result=Job::cancel($input['id']??'',$owner); api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?404:200); exit; +case 'retry': $result=Job::retry($input['id']??'',$owner); api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?422:200); exit; +case 'log': $result=Job::log($id,$owner,$_GET['offset']??0); api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?404:200); exit; +case 'results': case 'series': case 'frame': case 'download': api_artifacts($action,$id,$owner); exit; +default: api_reply(false,null,['route'=>'Not found.'],404); exit; +} +function owned_job($id,$owner){ return is_string($id)&&preg_match('/^[a-f0-9]{32}$/',$id)?Job::get($id,$owner):null; } +function api_artifact_path(array $job,string $path): ?string { + $root=realpath(Job::directory($job));$file=realpath($path); + return $root!==false&&$file!==false&&is_file($file)&&is_readable($file)&&str_starts_with($file,$root.DIRECTORY_SEPARATOR)?$file:null; +} +function api_artifact_limit(): int { return max(0,(int)(lab_config()['max_result_bytes']??0)); } +function api_artifact_available(array $job,string $path): bool { + $file=api_artifact_path($job,$path);$size=$file===null?false:filesize($file); + return $size!==false&&$size<=api_artifact_limit(); +} +function api_send_artifact(array $job,string $path,string $name): void { + $file=api_artifact_path($job,$path); + if($file===null){api_reply(false,null,['artifact'=>'Artifact not found or not allowed.'],404);return;} + $stream=@fopen($file,'rb');$stat=$stream===false?false:fstat($stream);$limit=api_artifact_limit(); + if($stat===false||$stat['size']>$limit){if($stream!==false)fclose($stream);api_reply(false,null,['artifact'=>'Artifact exceeds the download size limit.'],413);return;} + $name=basename($name);header('Content-Type: application/octet-stream');header('Content-Disposition: attachment; filename="'.$name.'"');header('Content-Length: '.$stat['size']); + $remaining=$stat['size'];while($remaining>0&&!feof($stream)){$chunk=fread($stream,min(1048576,$remaining));if($chunk===false||$chunk==='')break;echo $chunk;$remaining-=strlen($chunk);}fclose($stream); +} +function api_downloads(array $job,array $manifest,array $selected): array { + $root=Job::directory($job);$artifacts=$root.'/artifacts';$links=[]; + $add=static function(string $id,string $label,string $path)use(&$links,$job):void{if(api_artifact_available($job,$path))$links[]=['id'=>$id,'label'=>$label];}; + $add('log','Run log',$root.'/worker.log'); + foreach($job['spec']['maps']??[] as $field=>$version){$meta=ParameterCatalog::schema()['map_fields'][$field]??null;if(!$meta)continue;$add('input-map:'.$field,'Input map · '.$meta['label'],$root.'/inputs/maps/'.$field.'.f32');$add('native-input:'.$field,'Native input · '.$meta['label'],$root.'/inputs/native/'.$field.'.sra');} + if(($job['kind']??'model')==='model'){ + $add('model-config','Model configuration',$root.'/runtime/GUI.cfg'); + $add('restart:plasim_restart','Model restart',$root.'/runtime/plasim_restart'); + foreach(glob($root.'/runtime/MOST_REST*')?:[] as $path){$name=basename($path);if(preg_match('/^MOST_REST(?:\\.[0-9]{5})?$/',$name))$add('restart:'.$name,$name,$path);} + } + if(($job['kind']??'')==='planet'){ + $add('terrain-recipe','Terrain recipe',$artifacts.'/planet.json'); + foreach(glob($artifacts.'/planet-work/N???_surf_*.sra')?:[] as $path){$name=basename($path);if(preg_match('/^N[0-9]{3}_surf_(0129|0172)\.sra$/',$name))$add('sra:'.$name,$name,$path);} + } + foreach($manifest['datasets']??[] as $position=>$item){ + $datasetId=$item['id']??'';if($datasetId!==($selected['id']??null))continue;$index=$selected; + $add('dataset-index:'.$datasetId,'Dataset index · '.($item['label']??'Dataset'),$artifacts.'/datasets/'.$datasetId.'/index.json'); + foreach($index['fields']??[] as $field){if(!is_array($field))continue;$name=$field['name']??'';if(preg_match('/^[A-Za-z][A-Za-z0-9_]*$/',$name))$add('field:'.$datasetId.':'.$name,($item['label']??'Dataset').' · '.($field['label']??$name),$artifacts.'/datasets/'.$datasetId.'/'.$name.'.f32');} + if(($job['kind']??'model')==='model'){ + $raw='MOST.'.sprintf('%05d',$position);$add('raw:'.$datasetId,'Raw model output · '.($item['label']??'Dataset'),$root.'/runtime/'.$raw.'.npz'); + $add('raw-metadata:'.$datasetId,'Raw model metadata · '.($item['label']??'Dataset'),$root.'/runtime/'.$raw.'_metadata.npz'); + } + if(($job['kind']??'')==='planet')foreach(glob($artifacts.'/planet-work/surface-*.npz')?:[] as $path){$name=basename($path);if(preg_match('/^(surface-[a-f0-9]{32})\.npz$/',$name,$parts)){$add('raw:'.$datasetId.':'.$name,'Raw terrain preview',$path);$add('raw-metadata:'.$datasetId.':'.$parts[1],'Raw terrain metadata',$artifacts.'/planet-work/'.$parts[1].'_metadata.npz');}} + } + return $links; +} +function api_artifacts(string $route,$id,$owner): void { + $job=owned_job($id,$owner);if(!$job){api_reply(false,null,['id'=>'Job not found.'],404);return;} + $root=Job::directory($job);$base=$root.'/artifacts';$manifest=Results::manifest($job)??['datasets'=>[],'latest'=>null]; + if($route==='download'){ + $artifact=is_string($_GET['artifact']??null)?$_GET['artifact']:'';$path=null;$name=''; + if($artifact==='log'){$path=$root.'/worker.log';$name='worker.log';} + elseif(preg_match('/^(input-map|native-input):([a-z][a-z0-9_]*)$/',$artifact,$match)&&isset($job['spec']['maps'][$match[2]],ParameterCatalog::schema()['map_fields'][$match[2]])){$native=$match[1]==='native-input';$name=$match[2].($native?'.sra':'.f32');$path=$root.'/inputs/'.($native?'native':'maps').'/'.$name;} + elseif($artifact==='model-config'&&($job['kind']??'model')==='model'){$path=$root.'/runtime/GUI.cfg';$name='GUI.cfg';} + elseif($artifact==='terrain-recipe'&&($job['kind']??'')==='planet'){$path=$base.'/planet.json';$name='planet.json';} + elseif(preg_match('/^dataset-index:([a-f0-9]{32})$/',$artifact,$match)&&Results::dataset($job,$match[1])){$path=$base.'/datasets/'.$match[1].'/index.json';$name='index.json';} + elseif(preg_match('/^field:([a-f0-9]{32}):([A-Za-z][A-Za-z0-9_]*)$/',$artifact,$match)){$index=Results::dataset($job,$match[1]);foreach($index['fields']??[] as $field)if(($field['name']??null)===$match[2]){$path=$base.'/datasets/'.$match[1].'/'.$match[2].'.f32';$name=$match[2].'.f32';break;}} + elseif(preg_match('/^raw:([a-f0-9]{32})(?::(surface-[a-f0-9]{32}\.npz))?$/',$artifact,$match)&&Results::dataset($job,$match[1])){ + if(($job['kind']??'model')==='model'&&empty($match[2])){ + foreach($manifest['datasets'] as $position=>$item)if(($item['id']??null)===$match[1]){$path=$root.'/runtime/'.sprintf('MOST.%05d.npz',$position);$name=basename($path);break;} + }elseif(($job['kind']??'')==='planet'&&!empty($match[2])){$name=$match[2];$path=$base.'/planet-work/'.$name;} + } + elseif(preg_match('/^raw-metadata:([a-f0-9]{32})(?::(surface-[a-f0-9]{32}))?$/',$artifact,$match)&&Results::dataset($job,$match[1])){ + if(($job['kind']??'model')==='model'&&empty($match[2])){ + foreach($manifest['datasets'] as $position=>$item)if(($item['id']??null)===$match[1]){$name='MOST.'.sprintf('%05d',$position).'_metadata.npz';$path=$root.'/runtime/'.$name;break;} + }elseif(($job['kind']??'')==='planet'&&!empty($match[2])){$name=$match[2].'_metadata.npz';$path=$base.'/planet-work/'.$name;} + } + elseif(preg_match('/^restart:(plasim_restart|MOST_REST(?:\.[0-9]{5})?)$/',$artifact,$match)&&($job['kind']??'model')==='model'){$name=$match[1];$path=$root.'/runtime/'.$name;} + elseif(preg_match('/^sra:(N[0-9]{3}_surf_(?:0129|0172)\.sra)$/',$artifact,$match)&&($job['kind']??'')==='planet'){$name=$match[1];$path=$base.'/planet-work/'.$name;} + if($path===null){api_reply(false,null,['artifact'=>'Artifact not found or not allowed.'],404);return;} + api_send_artifact($job,$path,$name);return; + } + if($route==='results'){ + $dataset=is_string($_GET['dataset']??null)?$_GET['dataset']:($manifest['latest']??'');$index=Results::dataset($job,$dataset); + if(!$index){api_reply(false,null,['dataset'=>'Dataset not found.'],404);return;} + api_reply(true,['index'=>$index,'dataset'=>$index,'datasets'=>$manifest['datasets'],'latest'=>$manifest['latest']??null,'downloads'=>api_downloads($job,$manifest,$index)]);return; + } + $dataset=is_string($_GET['dataset']??null)?$_GET['dataset']:($manifest['latest']??'');$index=Results::dataset($job,$dataset); + if(!$index){api_reply(false,null,['dataset'=>'Dataset not found.'],404);return;} + $field=is_string($_GET['field']??null)?$_GET['field']:'';$meta=null; + foreach($index['fields']??[] as $candidate)if(($candidate['name']??null)===$field)$meta=$candidate; + if(!$meta||!preg_match('/^[A-Za-z][A-Za-z0-9_]*$/',$field)){api_reply(false,null,['field'=>'Field not found.'],404);return;} + $shape=$meta['shape']??[];$level=filter_var($_GET['level']??0,FILTER_VALIDATE_INT);$time=filter_var($_GET['time']??0,FILTER_VALIDATE_INT); + if(!is_array($shape)||$level===false||$time===false||$level<0||$time<0||$level>=(int)($shape[1]??0)||$time>=(int)($shape[0]??0)){api_reply(false,null,['selection'=>'Selection out of range.'],416);return;} + $dir=$base.'/datasets/'.$dataset; + if($route==='series'){$stats=json_decode((string)@file_get_contents($dir.'/'.$field.'.json'),true)??[];api_reply(true,['time'=>$index['time']??[],'time_unit'=>$index['time_unit']??'','mean'=>array_column($stats['mean']??[],$level),'min'=>array_column($stats['min']??[],$level),'max'=>array_column($stats['max']??[],$level),'unit'=>$meta['unit']??'']);return;} + if($route==='frame'){ + $lat=count($index['lat']??[]);$lon=count($index['lon']??[]);$count=$lat*$lon;$bytes=$count*4;$limit=max(0,(int)(lab_config()['max_field_memory_bytes']??8388608));$file=api_artifact_path($job,$dir.'/'.$field.'.f32');$stream=$file===null?false:@fopen($file,'rb'); + $offset=(($time*(int)$shape[1]+$level)*$count)*4; + if(!$stream||$count<1||$bytes>$limit||fseek($stream,$offset)!==0){if($stream)fclose($stream);api_reply(false,null,['frame'=>'Frame unavailable.'],404);return;} + $data=fread($stream,$bytes);fclose($stream);if($data===false||strlen($data)!==$bytes){api_reply(false,null,['frame'=>'Frame is incomplete.'],404);return;} + header('Content-Type: application/octet-stream');header('X-Frame-Shape: '.$lat.','.$lon);echo $data;return; + } + api_reply(false,null,['route'=>'Not found.'],404); +} diff --git a/web/views/elements/error.php b/web/views/elements/error.php new file mode 100644 index 0000000..50a91a5 --- /dev/null +++ b/web/views/elements/error.php @@ -0,0 +1,5 @@ + $name]); +$bootstrap = [ + 'api' => $api, +]; +?> + + $bootstrap]) ?>