diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..24a8e87 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.png filter=lfs diff=lfs merge=lfs -text diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..707ccb8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,339 @@ +# AGENTS.md + +This document describes generic architecture and conventions for AI coding agents. + +**Scope:** these conventions are written from a C++ / numerical-simulation codebase (plane-wave +DFT, Q3/ESSE) and apply as-is there. For other stacks, apply the same principles by analogy — the +naming rules and OOP antipatterns hold across languages, but tooling-specific items (GTest, ESSE +schema `$ref`s) don't. Skip a rule silently if it names a tool or file layout that doesn't exist +in the repo you're reviewing. + +**Precedence:** this file owns *code conventions*. `AGENTS-code-review-tb.md`, where present, +owns *review process, severity, and tone*, and cites the rules here by name rather than restating +them. + +## 1. Conventions + +### 1.1. Design Patterns + +- **Factory**: when multiple implementations are possible - e.g. multiple exchange-correlation functionals, or multiple k-point samplers. +- **Object-oriented design**: define abstract interfaces for components that have multiple implementations - e.g. Method → PseudopotentialMethod, PlaneWaveMethod, Model → DFTModel, HFModel, etc. + +### 1.2. OOP Guidelines & Antipatterns + +**Prefer polymorphism over type-checking chains.** Instead of: + +```cpp +// ❌ ANTIPATTERN: long if-chain checking object type +if (functional.is_lda()) { + compute_lda_energy(...); +} else if (functional.is_gga()) { + compute_gga_energy(...); +} else if (functional.is_meta_gga()) { + compute_meta_gga_energy(...); +} +``` + +Use: + +```cpp +// ✅ CORRECT: polymorphic dispatch via virtual method +Real energy = functional.compute_energy(density); +``` + +**Key principles:** + +- **Single Responsibility**: each class does one thing. If a class has methods for reading, computing, and writing, split it. +- **Open/Closed**: add new behavior by adding new classes, not by adding `if` branches to existing code. +- **Interface Segregation**: keep interfaces small. Don't force implementors to provide methods they don't need. +- **No `is_xxx()` type queries**: if you need `is_ultrasoft()`, `is_paw()`, `is_norm_conserving()`, your design likely needs a virtual method instead. +- **Favor composition over inheritance** for combining behaviors: use mixins (e.g., `BinarySerializableMixin`) rather than deep inheritance hierarchies. +- **Use factories** to create the right subclass from runtime configuration (e.g., `create_diagonalizer("davidson")`). + +### 1.3. Logging + +- All output via logger +- Allow log level to be set via command line argument (critical, error, warn, info, debug, trace). Default is error. +- Only rank 0 outputs (MPI-aware initialization) +- No print statement in the log + +### 1.4. Testing + +- Unit tests: `tests/unit/` (GTest) +- Integration tests: `tests/integration/` + +### 1.5. Comment Style + +- **Multiline docstrings** must have `/**` on its own line followed by the comment body: + +```cpp +// ✅ CORRECT +/** + * Compute the angular phase factor (-i)^l. + */ + +// ❌ INCORRECT +/** Compute the angular phase factor (-i)^l. + */ +``` + +- Use `///` for single-line doc comments. +- Use `//` for inline implementation comments. +- Never use bare `/* ... */` for documentation; use `/** ... */`. + +### 1.6. Linter + +Use linting for autoformatting the codebase. Consider language-specific tools and/or prettier. + +### 1.7. Pre-commit + +Use pre-commit to run linters and formatters automatically. + +### 1.7.1. Build output (`dist/`) — TypeScript packages + +**This repo no longer tracks `dist/`.** It is gitignored, and CI publishes WIP +release tarballs on `[release]` commits through the reusable workflow in +`mat3ra/actions` (see `RELEASING.md`, `.github/workflows/release-wip.yml`). +Do not re-add build output to a commit, and do not add a hook that stages it. + +The rest of the `@mat3ra/*` family has not all migrated yet, and the two models +fail in opposite directions, so **check before you commit rather than assuming +either one**: + +```sh +git ls-tree -r origin/main --name-only | grep -c '^dist/' +``` + +Non-zero means that repo still tracks build output. There, committing a `src/` +change without the matching `dist/` leaves the repository — and any consumer +installing from git — on the old code. A brand-new module is the dangerous +case: its `dist/` file is absent entirely, so the emitted code imports +something that does not exist and the package throws at runtime rather than +merely behaving as the old version. In those repos, before committing: + +```sh +npm run transpile # or: npm run build +git add dist/ +``` + +Zero means the repo has migrated, as this one has: let CI build it, and treat +a `dist/` diff in `git status` as something to leave alone. + +#### The pre-commit hook is dormant here + +`.husky/pre-commit` exists but never runs: `package.json` has no +`"prepare": "husky install"`, so a fresh clone arms no hooks. Arming it as-is +would break commits — the hook's first line is `npx lint-staged`, and +`lint-staged` is neither a dependency nor configured anywhere in the repo. If +you want the hook live, add `lint-staged` and its config in the same change; +do not add `prepare` on its own. + +### 1.8. GitHub Actions + +Use GitHub Actions to run tests and linters automatically. + +#### 1.8.1. Building a WIP test release of a `@mat3ra/*` package + +`@mat3ra/*` packages don't commit their build output (`dist/`) to git, and don't have a +local/manual publish path — CI is the only thing that builds and publishes a tarball. To let +a consumer install a not-yet-merged commit of a `@mat3ra/*` package (e.g. to test a fix in +`code` from a branch in `made` before `code`'s PR merges), publish a **WIP pre-release**: + +1. **Push a commit with `[release]` anywhere in its message** to the package repo (any + branch). Its `.github/workflows/release-wip.yml` calls a reusable workflow in + [`mat3ra/actions`](https://github.com/mat3ra/actions) that builds, packs, and publishes the + package as a GitHub **pre-release** tarball asset tagged `wip-` (e.g. + `wip-e8ed741`). Each commit gets its own immutable tag — the asset URL never changes + content under you. +2. **Install it in a consumer** — no local tooling or cloned `mat3ra/actions` needed, just a + URL in `package.json` in place of a normal semver range: + + ```json + "@mat3ra/code": "https://github.com/mat3ra/code/releases/download/wip-e8ed741/code.tgz" + ``` + + Then a plain `npm install` resolves it like any other tarball dependency. +3. **Re-publishing on the same commit** (e.g. re-running the workflow) uploads over that + commit's existing asset rather than minting a new tag. Because the URL doesn't change, a + plain `npm install` in the consumer won't refetch it — npm caches by URL and + `package-lock.json` pins the old `integrity` hash. Force it explicitly: + `npm install @mat3ra/@ --force`. +4. Once the source commit's real PR merges and a normal registry version is published, + switch the consumer back to a semver range/pin — the WIP tarball URL is only for testing + pre-merge changes. + +Full details (tag scheme, cleanup of stale pre-releases, the exact reusable workflow +contract): see [`mat3ra/actions`](https://github.com/mat3ra/actions)'s README. + +### 1.9. Demo deploys + +Packages with a standalone demo (`npm run build:standalone`) publish it to two +places, and the two disagree about the base path: + +| Target | Serves from | Built by | Branches | +| ------------- | --------------------------------- | --------------------------------- | -------- | +| GitHub Pages | `mat3ra.github.io//` | `deploy-bundle` in `cicd.yml` | `main` only — the job `needs: [publish]`, which is gated on `main` | +| Netlify | the site root | `netlify.toml` | every branch and pull request | + +Never hardcode Vite's `base`. Read it from `VITE_BASE` with the Pages subpath +as the default, so the root-served target opts in rather than the subpath one +silently breaking: + +```ts +base: process.env.VITE_BASE || "//", +``` + +A wrong `base` fails in the least obvious way: `index.html` loads, then every +asset 404s, so the deploy looks like a blank page rather than a build error. + +Netlify installs with `npm ci`, which refuses to run at all when +`package.json` and `package-lock.json` disagree. Our own CI and the README +both use `npm install --legacy-peer-deps`, which papers over exactly that +drift — so adding a dependency without regenerating the lockfile passes +every local and CI check and then fails only on deploy, in seconds, with +`EUSAGE ... Missing: from lock file`. After changing dependencies, run: + +```bash +npm install --package-lock-only --legacy-peer-deps +npm ci --dry-run # must report no error +``` + +## 2. !!! IMPORTANT !!!: Code Editing & Development HARD RULES + +### 2.1. HARD RULE 1: Never commit without explicit ask from user + +NEVER commit changes using `git commit` without the user's explicit ask. Leave files in the working directory for the user to review. + +### 2.2. HARD RULE 2: use `/agents/workdir/` for ALL scratch files. + +NEVER create any throwaway files at the top level of the project directory (``). The top level of `` must remain clean and contain only tracked project files. All throw-away scripts — debug helpers, patch scripts, test snippets, one-off analysis scripts — MUST go in `/agents/workdir/tmp/`. Create that directory if it does not exist. Examples of files that belong in `/agents/workdir/tmp/`: `debug_*.py`, `fix_*.py`, `patch_*.py`, `print_*.py`, `test_*.py` / `test_*.cpp` that are not formal tests in `tests/`, any other ephemeral script written to inspect or patch source code. Any potentially reusable agent artifacts should be either in `/agents/workdir/reusable` (if they're intended to be used in the current project only) or in the repository's top-level `plan/` folder (see section 6) if they're plan or context documents intended to persist. NO EXCEPTIONS. + +### 2.3. HARD RULE 3: Always setup and use a virtual environment + +(`venv`) when working with Python. Do NOT install Python packages globally. Use pyenv to select python version(s). Create venv in the agents workdir directory as explained in the next item + +## 3. HARD RULE 4: names with no abbreviations, Snake for Py, Camel for JS/TS, classnames + +Variables, functions, methods, field names, class names, type names, file names. Always use full, descriptive names. For example: + +- ❌ `nkp`, `nbnd`, `nspin`, `npw`, `ik`, `ib`, `ig`, `ia`, `et`, `pw`, `ppset` +- ✅ `number_of_kpoints`, `number_of_bands`, `number_of_spin_components`, `number_of_plane_waves`, `kpoint_index`, `band_index`, `g_index`, `atom_index`, `eigenvalues`, `planewave_basis`, `pseudopotential_set` +- ❌ `Vec3`, `Mat3`, `IVec3` +- ✅ `Vector3D`, `Matrix3x3`, `IntegerVector3D` + +Also: + +- Use **snake_case** for variables, functions, and file names. +- Use **PascalCase** for classes and structs. +- Member variables use trailing underscore: `planewave_basis_`, `number_of_bands_`. +- General rule: if a name looks abbreviated, spell it out. Exceptions could be made for complex physical and mathematical expressions where the abbreviation is widely known and used for compacting the representation. However, even in these cases, try to spell it out. Make sure to make it obvious from the context what the abbreviation means. + +## 4. Other + +### 4.1. JSON Formatting + +- **HARD RULE**: JSON schemas MUST follow ESSE formatting conventions: + - **4-space indentation** (matching ESSE `.prettierrc`) + - **100 character print width** + - **Double quotes** only (standard JSON) + - **Trailing newline** at end of file + - **Bracket spacing** enabled (e.g., `{ "key": "value" }`) + - All Q3 result schemas must `$ref` their corresponding ESSE schema in `schemas/esse/schema/` + +### 4.2. Code Reviews + +- **HARD RULE**: All PR reviews must be saved locally according to the following strict directory and naming structure: + - Directory: `reviews///pr-/` + - Filename: `comments-.md` (using the 7-character short hash of the latest commit in the PR) + - Example: `reviews/mat3ra/q3/pr-10/comments-b307df4.md` + +## 5. Concrete Review Examples + +When conducting PR reviews, look for these specific architectural and hygiene violations to flag. + +### 5.1. Magic Numbers for Tolerance + +**❌ ANTIPATTERN (Flag this):** +```cpp +// QE uses a looser convergence threshold for empty (unoccupied) bands: +int number_of_occupied_bands = quantum_system.number_of_occupied_bands_per_spin(); +Real empty_band_tolerance = std::max(5.0 * tolerance_, 1.0e-5); +``` + +**✅ CORRECT:** +The `5.0` multiplier and `1.0e-5` lower bound should be defined centrally. +```cpp +Real empty_band_tolerance = std::max( + math::tolerances::empty_band_relaxation_factor * tolerance_, + math::tolerances::empty_band_relaxation_floor +); +``` + +### 5.2. Global Namespace Pollution + +**❌ ANTIPATTERN (Flag this):** +```cpp +extern "C" { +/** + * ZGEMM: Complex double-precision general matrix-matrix multiply. + * C := alpha * op(A) * op(B) + beta * C + */ +void zgemm_(const char* transa, ...); +} +``` + +**✅ CORRECT:** +Do not introduce bare C-style wrappers into the global namespace. Wrap them in a class or at least an inner namespace. +```cpp +namespace q3::blas { + extern "C" { + void zgemm_(const char* transa, ...); + } +} +``` + +### 5.3. Loop Duplication & DRY Violations + +**❌ ANTIPATTERN (Flag this):** +```cpp +switch (degree) { + case 0: + for (int index = 0; index < mesh_size; index++) { + Real argument = q_value * radial_grid[index]; + if (std::abs(argument) < math::tolerances::bessel_series_expansion_threshold) { ... } + } + break; + case 1: + for (int index = 0; index < mesh_size; index++) { + Real argument = q_value * radial_grid[index]; + if (std::abs(argument) < math::tolerances::bessel_series_expansion_threshold) { ... } + } + break; +} +``` + +**✅ CORRECT:** +While hoisting the switch outside the loop is good for performance, the repetitive boundary checks inside each loop violate DRY. Extract the small-argument asymptotic expansions into inline helper functions to clean this up. + +## 6. Plan Folder (`plan/`) + +Design documents and durable agent context live in a top-level `plan/` folder, filed by where +the work has got to. The folder a document sits in is the claim being made about it, so moving +it is part of doing the work — not bookkeeping to be done later: + +- `plan/upcoming/` — agreed direction, not built yet. Safe to change freely; nothing depends on it. +- `plan/review/` — built and on a branch, not yet proven. Waiting on CI, a PR, or a deploy. +- `plan/implemented/` — shipped. Kept as the record of why the code looks the way it does. On the + way in, add a `## Status` section at the top recording what shipped, divergences from the plan, + and what remains open (real open items also get an entry in `upcoming/`). +- `plan/context/` — reference material that is not a plan: investigations, measurements, + background, and context dumps written to retain state when switching models, machines, or + sessions. + +Never edit a document in `implemented/` to match the code — rewriting history loses the reason a +decision was made, which is the only thing the document is still good for; correct it with a +`## Status` note instead. Name documents `-.md`, e.g. +`2026-08-16-Containerized-Venv-Plan.md` — dated, with the tracker ticket referenced inside the +document text (a `**Ticket:**` line at the top), not in the file name: repositories are public +while the tracker is private. The canonical `plan/README.md` to copy when introducing +the folder to a repository lives in `mat3ra/agents` under `templates/plan/README.md`. diff --git a/UIUX_IMPROVEMENTS.md b/UIUX_IMPROVEMENTS.md new file mode 100644 index 0000000..eed3d5f --- /dev/null +++ b/UIUX_IMPROVEMENTS.md @@ -0,0 +1,107 @@ +# Job Designer — UI/UX improvement proposals + +Companion to the Materials Designer exercise: prioritized interface proposals for the Job +Designer, grounded in the current code, with interactive mockups in [`mockups/`](mockups/). + +The designer today is a five-tab shell (`src/components/Job.jsx`) around three packages: +the workflow editor (`@mat3ra/workflow-designer`), the compute form (`Compute` from +`@mat3ra/ive`), and results/files views (`@mat3ra/jove`). Most of what a user must *do* to +create a job — pick materials, pick a workflow, configure compute, submit — hides behind +the "Select Job Actions" dropdown built in `Job.jsx#getDefaultActions`, and the numbered +tabs (`1. MATERIALS / 2. WORKFLOW / 3. COMPUTE` from `TAB_NAVIGATION_CONFIG` in +`@mat3ra/jode`) imply a sequence without tracking progress through it. + +## Observations (current state) + +1. **The path is hidden.** Select materials / workflow / parent / dataset and Submit all + live in one dropdown menu; nothing on screen says what a new job still needs before it + can run. Tabs are numbered like steps but carry no completion state. +2. **No cross-tab context.** On the Compute tab there is no trace of which material or + workflow is selected; on the Workflow tab, no trace of compute. Every check requires a + tab switch. +3. **Compute answers no questions.** The form opens with four red "The field is required" + errors before the user has touched anything, cluster/queue are bare selects, cluster + status is an external link, and nothing estimates core-hours, cost, or queue wait. +4. **Workflow tab noise.** A second "Compute" sub-tab inside the Workflow tab duplicates + the top-level tab; raw UUIDs are printed on every subworkflow card and flowchart node; + "idle" status chips decorate a job that was never submitted; the flowchart pane stays + light-themed inside the dark shell. +5. **Submit is a leap of faith.** No preflight: nothing validates compute against cluster + limits or shows the cost before the job leaves. After submit the user lands on the same + editing view; Results/Files tabs appear only later and are passive lists. +6. **Materials are a lone canvas.** The Materials tab is a full-bleed 3D viewer with no + metadata panel; for multi-material jobs the set switcher hides inside the Workflow tab, + and the "runs N times" consequence of a materials set is never stated. + +## Proposals + +### A — One glance, one path +- **A1 · Readiness rail.** Replace the numbered full-width tabs + actions dropdown with a + left rail of lifecycle steps — Material, Workflow, Compute, Review & Submit — each + showing its current selection summary and state (complete / needs attention / empty). + The three "Select …" dialogs become "Change" affordances on their steps. +- **A2 · Context strip.** A persistent strip under the header with chips for material, + workflow, compute and estimated cost — visible from every step, each chip a shortcut. +- **A3 · First-class Submit.** Submit is a primary header button with live preflight + state (disabled explains *what's missing*), replaced by Terminate while running. + +### B — Compute that answers "what will this run, and what will it cost?" +- **B1 · Cluster cards.** Replace bare selects with selectable cards: hardware summary, + per-core-hour price, live queue-wait badge (inline; kills the "See cluster status" link). +- **B2 · Live estimate.** Side panel derives core-hours = nodes × cores × walltime, price, + queue ETA, and quota impact as the form changes; flags requests that exceed queue limits. +- **B3 · Presets.** Debug / Standard / Production (and "same as last job") one-click fills. +- **B4 · Progressive validation.** Validate on interaction and at preflight — never render + a screen of red required-field errors on first paint. + +### C — Review & submit with confidence +- **C1 · Preflight checklist.** Submit opens a check run — material set, workflow + parameters render, compute within cluster/queue limits, cost within budget — with + pass / warn / fail rows; fails deep-link to the offending step, warns are acknowledgeable. +- **C2 · Post-submit hand-off.** Successful submit transitions the designer into monitor + mode (F1) instead of leaving a stale editor open. + +### D — Workflow tab clarity +- **D1 · One Compute.** Drop the duplicated "Compute" sub-tab inside the Workflow tab. +- **D2 · Humane metadata.** UUIDs move behind a copy-id affordance; status chips appear + only once a job has been submitted. +- **D3 · Unit inspector.** Clicking a flowchart node opens a right drawer with that unit's + important settings, replacing the Overview / Important settings / Detailed view sub-tab + bounce. +- **D4 · Theme parity.** Token-driven theming for the flowchart pane so it follows the + shell's theme. + +### E — Materials in context +- **E1 · Materials tray.** Chips for the materials set above the viewer with add / remove / + switch inline, and explicit "this job runs N times — once per material" copy. +- **E2 · Metadata panel.** Formula, lattice, atom count, source id beside the 3D viewer. + +### F — The job lives after Submit +- **F1 · Run monitor.** The post-submit view becomes live: per-unit timeline with + statuses and durations, streaming log tail, convergence chart, files appearing as + produced, results summary on finish. +- **F2 · Lifecycle header.** Draft → Queued → Running → Finished timeline with timestamps + in the header, replacing the lone status-colored icon (`iconCls: text-${job.statusCls}`). + +## Mockups + +| # | File | Covers | +|---|------|--------| +| 01 | [`mockups/01-guided-designer.html`](mockups/01-guided-designer.html) | A1 A2 A3 · D2 · E1 E2 | +| 02 | [`mockups/02-compute-cost.html`](mockups/02-compute-cost.html) | B1 B2 B3 B4 | +| 03 | [`mockups/03-preflight-submit.html`](mockups/03-preflight-submit.html) | C1 C2 | +| 04 | [`mockups/04-run-monitor.html`](mockups/04-run-monitor.html) | F1 F2 · C2 | + +Each mockup is a self-contained HTML file (no build step, no network) — open directly in a +browser. + +## Rollout sketch + +| Phase | Scope | Proposals | Mostly lands in | +|-------|-------|-----------|-----------------| +| 1 — de-noise | Low-risk cleanups inside current layout | B4 · D1 · D2 · A3 | job-designer, `@mat3ra/ive`, `@mat3ra/workflow-designer` | +| 2 — the guided designer | Layout change: rail + context strip + compute redesign + preflight | A1 · A2 · B1–B3 · C1 · E1 · E2 | job-designer (`Job.jsx`), `@mat3ra/ive`, `@mat3ra/cove` | +| 3 — the living job | Monitoring + inspector + theming | F1 · F2 · C2 · D3 · D4 | `@mat3ra/jove`, `@mat3ra/workflow-designer` | + +Phase 1 is deliberately shippable without design sign-off on the new layout; Phase 2 is +where the designer stops being a filing cabinet; Phase 3 closes the loop after submission. diff --git a/mockups/01-guided-designer.html b/mockups/01-guided-designer.html new file mode 100644 index 0000000..e31a9e3 --- /dev/null +++ b/mockups/01-guided-designer.html @@ -0,0 +1,424 @@ + + + + + +Mockup 01 — Guided designer + + + + +
+
+
+
+
Molecular Dynamics — Si, mp-149
+
Demo Project · created just now
+
+
+ DRAFT + All changes saved + compute not configured + + +
+ +
+ + + + +
+ +
+ + +
+ +
+

Material

+

The structure(s) this job will run on. Add more to turn this into a batch.

+
+ Si₂ · mp-149 + + 1 material → the workflow runs once. +
+
+
+ + + + + + + + + + + + drag to rotate · scroll to zoom +
+
+ + + + + + +
FormulaSi₂
LatticeFCC · a = 3.867 Å
Space groupFd-3m (227)
Atoms in cell2
Sourcemp-149
+
+
+
+ + +
+

Workflow

+

What will run, in order. Open a subworkflow to tune its unit parameters.

+
+
+ I + CP-MD
Quantum ESPRESSO · cp.x
+ 2 units + +
+
+ II + DeePMD
DeePMD-kit · dp train
+ 3 units + +
+
+ 5 units total · no status chips until the job runs + + + +
+
+
+ + +
+

Compute

+

Pick a preset to start — everything stays editable. Full estimator in mockup 02.

+
+ + + +
+
No compute selected yet — choose a preset above.
+ +
+ + +
+

Review & submit

+

Preflight runs these checks again at submit. Full flow in mockup 03.

+
+
Material set — Si₂ (mp-149)
+
Workflow renders — 5 units, no template errors
+
Compute configured
+
Within monthly core-hour budget
+
+
+ + compute not configured +
+
+
+
+ +
Submitted. Job is queued on cluster-007 — opening monitor…
+ + + + diff --git a/mockups/02-compute-cost.html b/mockups/02-compute-cost.html new file mode 100644 index 0000000..9fbf060 --- /dev/null +++ b/mockups/02-compute-cost.html @@ -0,0 +1,315 @@ + + + + + +Mockup 02 — Compute & cost + + + + +
+

Compute

+ Molecular Dynamics — DeePMD · Si₂ (mp-149) · Demo Project +
+ +
+
+
+

Cluster — live queue status inline

+
+ + + +
+
+ +
+

Presets

+
+ + + + +
+
+ +
+

Resources

+
+
+ +
+ + 1 + +
+
up to 16 per job
+
+
+ +
+ + 16 + +
+
128 available on cluster-007
+
+
+ +
+ + 4h + +
+
queue OR allows up to 12 h
+
+
+ + + +
+
+

Nothing is marked red until you interact — validation runs on change and again at preflight.

+
+
+ + +
+ + + + diff --git a/mockups/03-preflight-submit.html b/mockups/03-preflight-submit.html new file mode 100644 index 0000000..07583e9 --- /dev/null +++ b/mockups/03-preflight-submit.html @@ -0,0 +1,283 @@ + + + + + +Mockup 03 — Preflight & submit + + + + + + + + + + + diff --git a/mockups/04-run-monitor.html b/mockups/04-run-monitor.html new file mode 100644 index 0000000..d4b505e --- /dev/null +++ b/mockups/04-run-monitor.html @@ -0,0 +1,346 @@ + + + + + +Mockup 04 — Run monitor + + + + +
+
+

Molecular Dynamics — Si, mp-149

+ elapsed 00:00 + + + +
+
+ Draft 14:02 + + Queued 14:05 + + Running 14:07 + + Finished +
+
+ +
+ +
+

Units

click to filter the log
+
+
3 files · stdout updated just now
+
+ + +
+
+
Total energy
−19.2634 eV
+
Pressure
0.42 kbar
+
Wall used
of 4 h
+
+
+ +
+
+

Total energy convergence

+ cp-md + + ΔE — +
+
+ +
+
+
+ +
+

Log

all units
+
+
+
+
+ + + + diff --git a/mockups/README.md b/mockups/README.md new file mode 100644 index 0000000..c0298a0 --- /dev/null +++ b/mockups/README.md @@ -0,0 +1,16 @@ +# Job Designer — UI/UX mockups + +Interactive, self-contained HTML mockups for the proposals in +[`../UIUX_IMPROVEMENTS.md`](../UIUX_IMPROVEMENTS.md). No build step, no network — open any +file directly in a browser. + +| # | File | Shows | Try | +|---|------|-------|-----| +| 01 | `01-guided-designer.html` | Readiness rail, context strip, first-class Submit (A1–A3), materials tray (E1–E2), de-noised workflow list (D2) | Open the **Compute** step and pick a preset — the rail, chips and Submit react. Then submit. | +| 02 | `02-compute-cost.html` | Cluster cards, presets, live cost/quota estimate, progressive validation (B1–B4) | Switch clusters, push cores/walltime past the limits, watch the estimate and flags. | +| 03 | `03-preflight-submit.html` | Preflight checklist with pass / warn / fail, deep-link fixes, submit hand-off (C1–C2) | Let the checks run, click *Fix → set 12 h*, acknowledge the warning, submit. | +| 04 | `04-run-monitor.html` | Live run monitor: lifecycle timeline, per-unit progress, streaming log, convergence chart (F1–F2) | Watch the simulated run; click a unit to filter the log; hover the chart; replay. | + +The four files share one set of design tokens (dark shell matching the standalone demo, +`#8b6cf0` accent, reserved status hues always paired with an icon + label) so they read as +one product. diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..f294ea7 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,26 @@ +# Deploy target for the standalone demo (site: mat3ra-job-designer). +# +# The same `build:standalone` bundle is also published to GitHub Pages by +# .github/workflows/cicd.yml, but only from `main`. Netlify covers the gap: +# it builds every branch and pull request, so a designer or reviewer can open +# a work-in-progress branch without checking it out. + +[build] + command = "npm run build:standalone" + publish = "build" + +[build.environment] + NODE_VERSION = "20" + # The dependency tree does not satisfy peer ranges cleanly; this matches + # what CI and the README already use. Setting NPM_FLAGS also makes Netlify + # run `npm install` rather than `npm ci`, which is what we want here. + NPM_FLAGS = "--legacy-peer-deps" + # Netlify serves the app from the domain root, unlike GitHub Pages, which + # serves it from the /job-designer/ subpath. See vite.config.ts. + VITE_BASE = "/" + +# Single-page app: any path has to return index.html rather than a 404. +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 diff --git a/package-lock.json b/package-lock.json index b8601cd..621b865 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "@mat3ra/ade": "2026.8.18-0", "@mat3ra/ave": "2026.8.19-1", "@mat3ra/code": "2026.8.18-0", - "@mat3ra/cove": "2026.8.19-4", + "@mat3ra/cove": "https://github.com/mat3ra/cove/releases/download/wip-03c8439/cove.tgz", "@mat3ra/esse": "2026.8.18-2", "@mat3ra/ive": "2026.8.19-0", "@mat3ra/jode": "2026.8.19-1", @@ -4072,9 +4072,9 @@ } }, "node_modules/@mat3ra/cove": { - "version": "2026.8.19-4", - "resolved": "https://registry.npmjs.org/@mat3ra/cove/-/cove-2026.8.19-4.tgz", - "integrity": "sha512-7Zd0ZaZ1Bmgjg+bLNeLC59C/ss6gEL3OeIO9Cp+F46WJajulJysgYwT3LDAZrZcMp/Aj7Er5LH6Fxj2C7dQSZg==", + "version": "0.0.0", + "resolved": "https://github.com/mat3ra/cove/releases/download/wip-03c8439/cove.tgz", + "integrity": "sha512-alIW33A5JuvM8KCnBNMTSTyUIa1sgS5HRByz+gmHv8Qmst4geQYCAr/rgONLGnSDiuR99aqtpg6kO7A2wPq52A==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index e6ca8e6..90dfed9 100644 --- a/package.json +++ b/package.json @@ -55,11 +55,16 @@ }, "license": "Apache-2.0", "devDependencies": { - "@mat3ra/cove": "2026.8.19-4", - "@mat3ra/wave.js": "2026.8.19-0", + "@babel/core": "^7.24.3", + "@babel/plugin-proposal-class-properties": "^7.18.6", + "@babel/preset-env": "^7.24.3", + "@babel/preset-react": "^7.24.1", + "@babel/preset-typescript": "^7.24.1", + "@exabyte-io/eslint-config": "^2025.1.15-0", "@mat3ra/ade": "2026.8.18-0", "@mat3ra/ave": "2026.8.19-1", "@mat3ra/code": "2026.8.18-0", + "@mat3ra/cove": "https://github.com/mat3ra/cove/releases/download/wip-03c8439/cove.tgz", "@mat3ra/esse": "2026.8.18-2", "@mat3ra/ive": "2026.8.19-0", "@mat3ra/jode": "2026.8.19-1", @@ -71,6 +76,7 @@ "@mat3ra/standata": "2026.8.18-0", "@mat3ra/tsconfig": "^2024.6.3-0", "@mat3ra/utils": "2026.8.18-1", + "@mat3ra/wave.js": "2026.8.19-0", "@mat3ra/wode": "2026.8.18-0", "@mat3ra/workflow-designer": "2026.8.19-1", "@mat3ra/wove": "2026.8.19-0", @@ -84,26 +90,9 @@ "@types/node": "^20.11.30", "@types/react": "^17.0.2", "@types/react-dom": "^17.0.2", - "@vitejs/plugin-react": "^4.3.4", - "lodash": "^4.17.4", - "mathjs": "^3.9.0", - "react": "^17.0.2", - "react-dom": "^17.0.2", - "reactflow": "^11.7.2", - "tsx": "^4.22.4", - "typescript": "^5.6.6", - "underscore": "^1.8.3", - "underscore.string": "^3.3.4", - "vite": "^6.0.7", - "vite-plugin-node-polyfills": "^0.25.0", - "@babel/core": "^7.24.3", - "@babel/plugin-proposal-class-properties": "^7.18.6", - "@babel/preset-env": "^7.24.3", - "@babel/preset-react": "^7.24.1", - "@babel/preset-typescript": "^7.24.1", - "@exabyte-io/eslint-config": "^2025.1.15-0", "@typescript-eslint/eslint-plugin": "^5.9.1", "@typescript-eslint/parser": "^5.9.1", + "@vitejs/plugin-react": "^4.3.4", "babel-eslint": "^10.1.0", "eslint": "^7.32.0", "eslint-config-airbnb": "^19.0.2", @@ -119,6 +108,17 @@ "eslint-plugin-react": "^7.30.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-simple-import-sort": "^7.0.0", - "prettier": "2.5.1" + "lodash": "^4.17.4", + "mathjs": "^3.9.0", + "prettier": "2.5.1", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "reactflow": "^11.7.2", + "tsx": "^4.22.4", + "typescript": "^5.6.6", + "underscore": "^1.8.3", + "underscore.string": "^3.3.4", + "vite": "^6.0.7", + "vite-plugin-node-polyfills": "^0.25.0" } } diff --git a/plan/README.md b/plan/README.md new file mode 100644 index 0000000..1edf59f --- /dev/null +++ b/plan/README.md @@ -0,0 +1,45 @@ +# plan/ + +Design documents for work on this repo, filed by where the work has got to. The folder a document +sits in is the claim being made about it, so moving it is part of doing the work — not bookkeeping +to be done later. + +| folder | what is in it | +| -------------- | --------------------------------------------------------------------------------- | +| `upcoming/` | Agreed direction, not built yet. Safe to change freely; nothing depends on it. | +| `review/` | Built and on a branch, not yet proven. Waiting on a Jenkins run, a PR, or a deploy. | +| `implemented/` | Shipped. Kept as the record of why the code looks the way it does. | +| `context/` | Reference material that is not a plan — investigations, measurements, background. | + +## Working with these + +**A plan moves when its status changes, and it does not move silently.** On the way into +`implemented/`, add a `## Status` section at the top saying what actually shipped. That matters +more than it sounds: a plan is written before the work and is usually wrong somewhere, so a +document filed under `implemented/` without that section reads as "the code does this", which is a +claim nobody checked. + +Record three things there: + +- **What shipped** — one or two lines, and where the code lives. +- **Divergences** — where the built thing differs from the plan, and why. This is the part that + earns the document its place; a plan that matched reality exactly would not need it. +- **Still open** — anything the plan proposed that was not done. If it is real work, it also gets + an entry in `upcoming/`, because nobody goes looking for open items inside a file named + "implemented". + +**Do not edit a plan in `implemented/` to match the code.** Rewriting history loses the reason a +decision was made, which is the only thing the document is still good for. Correct it with a +`## Status` note instead. + +**A plan that has been superseded outright** stays in `implemented/` if its work shipped in some +other form — say so under Divergences. Only delete one if it was never acted on at all, and then +say so in the commit message. + +## Naming + +`-.md`, e.g. `2026-08-16-Containerized-Venv-Plan.md` — dated by when +the document was started. The tracker ticket goes inside the document (a `**Ticket:**` line at +the top), not in the file name: these repositories are public, while ticket keys point at a +private tracker that readers outside the organization cannot open. One ticket can have several +documents; keep them in the same folder only while they share a status. diff --git a/plan/context/2026-08-16-Job-Designer-Screenshots.md b/plan/context/2026-08-16-Job-Designer-Screenshots.md new file mode 100644 index 0000000..225d10d --- /dev/null +++ b/plan/context/2026-08-16-Job-Designer-Screenshots.md @@ -0,0 +1,25 @@ +# Job Designer screenshots: current state and mockups + +**Ticket:** [SOF-8023](https://mat3ra.atlassian.net/browse/SOF-8023) — Job Designer UX update. +Reference images for [`../upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md`](../upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md). +Current-state captures come from the standalone demo (`npm run dev`, standata content, +2026-08-15); mockup captures come from the interactive files in [`mockups/`](../../mockups/). +Stored via Git LFS (`*.png`, see `.gitattributes`). + +## Current state + +| | | +|---|---| +| ![Materials tab](images/2026-08-16-current-materials-tab.png) | **Materials tab** — full-bleed 3D viewer, no metadata panel, no multi-material tray. | +| ![Workflow tab](images/2026-08-16-current-workflow-tab.png) | **Workflow tab** — UUIDs and "idle" chips at design time, duplicated "Compute" sub-tab, light flowchart pane inside the dark shell. | +| ![Compute tab](images/2026-08-16-current-compute-tab.png) | **Compute tab** — four required-field errors on first paint; bare selects; no cost, limits, or queue information. | +| ![Actions dropdown](images/2026-08-16-current-actions-dropdown.png) | **"Select Job Actions" dropdown** — the entire creation path (and, in the webapp, Submit) hides here. | + +## Mockups + +| | | +|---|---| +| ![Guided designer](images/2026-08-16-mockup-01-guided-designer.png) | **01 · Guided designer** — readiness rail, context strip, first-class Submit (proposals A1–A3, D2, E1–E2). | +| ![Compute and cost](images/2026-08-16-mockup-02-compute-cost.png) | **02 · Compute & cost** — cluster cards, presets, live estimate with quota meter, progressive validation (B1–B4). | +| ![Preflight](images/2026-08-16-mockup-03-preflight-submit.png) | **03 · Preflight & submit** — pass/warn/fail checks with deep-link fixes (C1–C2). | +| ![Run monitor](images/2026-08-16-mockup-04-run-monitor.png) | **04 · Run monitor** — lifecycle timeline, per-unit progress, log tail, convergence chart (F1–F2). | diff --git a/plan/context/images/2026-08-16-current-actions-dropdown.png b/plan/context/images/2026-08-16-current-actions-dropdown.png new file mode 100644 index 0000000..7750634 --- /dev/null +++ b/plan/context/images/2026-08-16-current-actions-dropdown.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b703f1b8dfc03035b765f8420cfd01b0afe40493a3823a58eb3633dcb4cf51b6 +size 99033 diff --git a/plan/context/images/2026-08-16-current-compute-tab.png b/plan/context/images/2026-08-16-current-compute-tab.png new file mode 100644 index 0000000..52bfb3d --- /dev/null +++ b/plan/context/images/2026-08-16-current-compute-tab.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b64481cffa60cad79f962d17b131f3d10b7da9811710c3e1272d7f540eb4623f +size 93818 diff --git a/plan/context/images/2026-08-16-current-materials-tab.png b/plan/context/images/2026-08-16-current-materials-tab.png new file mode 100644 index 0000000..210110e --- /dev/null +++ b/plan/context/images/2026-08-16-current-materials-tab.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ef9cc6d58edf23bd8b3d7f4fed48c2aabef9ad811eb825467cbab44e2766ea5 +size 52180 diff --git a/plan/context/images/2026-08-16-current-workflow-tab.png b/plan/context/images/2026-08-16-current-workflow-tab.png new file mode 100644 index 0000000..f01e2fc --- /dev/null +++ b/plan/context/images/2026-08-16-current-workflow-tab.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4e4dc7f4794b6abea4808deb0394065039bd282bc328a6b95b5debfd6619ebe2 +size 89488 diff --git a/plan/context/images/2026-08-16-mockup-01-guided-designer.png b/plan/context/images/2026-08-16-mockup-01-guided-designer.png new file mode 100644 index 0000000..c0f9b97 --- /dev/null +++ b/plan/context/images/2026-08-16-mockup-01-guided-designer.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9069f40f412e84365c9dc69ecee4ccadce8fb4f0e9ac48d166f17dd62dda7b7f +size 78219 diff --git a/plan/context/images/2026-08-16-mockup-02-compute-cost.png b/plan/context/images/2026-08-16-mockup-02-compute-cost.png new file mode 100644 index 0000000..d0907fc --- /dev/null +++ b/plan/context/images/2026-08-16-mockup-02-compute-cost.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f37e44589fa58e8ca77965dc25c059c11b79fd3b12f4f36c4926312e0adb51c3 +size 84718 diff --git a/plan/context/images/2026-08-16-mockup-03-preflight-submit.png b/plan/context/images/2026-08-16-mockup-03-preflight-submit.png new file mode 100644 index 0000000..c87af1d --- /dev/null +++ b/plan/context/images/2026-08-16-mockup-03-preflight-submit.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ee71df85779904f5c9cf3742dafd5da9d96e2b69396c1a34e2fd26c7b3bd198 +size 60369 diff --git a/plan/context/images/2026-08-16-mockup-04-run-monitor.png b/plan/context/images/2026-08-16-mockup-04-run-monitor.png new file mode 100644 index 0000000..3c0ae32 --- /dev/null +++ b/plan/context/images/2026-08-16-mockup-04-run-monitor.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ddef300cd0b603fe09f1891a7119afdcbea892758ff5fe529da171a9015e2b0 +size 73552 diff --git a/plan/review/2026-08-16-Job-Designer-Phase-1-De-noise.md b/plan/review/2026-08-16-Job-Designer-Phase-1-De-noise.md new file mode 100644 index 0000000..e584761 --- /dev/null +++ b/plan/review/2026-08-16-Job-Designer-Phase-1-De-noise.md @@ -0,0 +1,141 @@ +# Job Designer Phase 1 — De-noise + +- **Ticket:** [SOF-8023](https://mat3ra.atlassian.net/browse/SOF-8023) — Job Designer UX update. +- **Parent:** [../upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md](../upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md) + (overview, current state, cove design-language audit, cross-cutting concerns). +- **Status:** built and on branches — every item below is implemented and in review. + Nothing merged yet, so this document sits in `review/` rather than `implemented/`. +- **Created:** 2026-08-16 · **Updated:** 2026-08-16 + +## Status — what shipped + +| Item | Where | PR | +|------|-------|----| +| 1.1 Progressive validation | `@mat3ra/ive` | [ive#6](https://github.com/mat3ra/ive/pull/6) | +| 1.2 Single Compute tab | `@mat3ra/workflow-designer` + job-designer | [workflow-designer#13](https://github.com/mat3ra/workflow-designer/pull/13), [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) | +| 1.3 Humane metadata | `@mat3ra/wove` + `@mat3ra/workflow-designer` | [wove#11](https://github.com/mat3ra/wove/pull/11), [workflow-designer#14](https://github.com/mat3ra/workflow-designer/pull/14) | +| 1.4 First-class Submit | job-designer | [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) | +| 1.5 Design-language groundwork | `@mat3ra/cove` | [cove#97](https://github.com/mat3ra/cove/pull/97) | +| 1.6 Visible error state | job-designer | [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) | + +Measured in the standalone demo with all package changes linked together: required-field +errors on a fresh Compute tab **4 → 0**; things labelled "Compute" on the Workflow tab +**2 → 1**; flowchart ids visible **8 → 0**; Submit moved from a dropdown item that +disappeared when unavailable to a header button that names what is missing. + +### Divergences from the plan as written + +- **1.3 landed in `@mat3ra/wove`, not `@mat3ra/workflow-designer`.** The ids and status + chips render in wove's `components/common/CardHeader` (used by `UnitCard` and + `WorkflowUnitCard`), traced from the rendered DOM. The plan had assigned it to + workflow-designer. +- **1.3's mechanism changed twice.** The plan proposed folding ids behind cove's `CopyId`. + It shipped instead as *hidden by default* with a **"Developer info"** toggle — the + approach the parallel SOF-8024 effort had specified (its items 1.1 and 1.5), adopted so + the two projects do not answer the same question differently. And it reaches wove through + a new `WoveDisplayOptionsProvider` context rather than props, because the flowchart cards + sit behind reactflow node data (`UnitsFlowchartContainer → UnitsFlowchart → node.data → + UnitNode → UnitCard`) where prop-drilling is not practical. +- **The cove defects were worse than audited.** `paletteDark` was not "identical to light" + but *missing* `background` / `text` / `action` / `border` / `icon` / `unitTypes` + entirely, so those read `undefined` in dark mode. Status `contrastText` values were + unusable (white on neon green at 1.7:1; `rgba(0, 0, 0, 0.23)` on red). Fixed with values + computed and asserted by `tests/palette.tests.ts`, not chosen by eye. +- **An unplanned prerequisite: the build output was never being committed.** `dist/` is + tracked and shipped by these packages, but the husky `pre-commit` hook that regenerates + it had never run — husky was not a dependency and no `prepare` script installed it. Four + packages had landed src changes with stale or entirely absent `dist`, including new + modules whose emitted code imported files that were never built. Fixed per repo, and + recorded in `AGENTS.md` §1.7.1. +- **1.4 needed a `shouldComponentUpdate` change** that the plan did not anticipate: the + component's mixins only consider the job entity, so the terminate confirmation could + never have rendered without it. + +### Still open + +- Nothing from Phase 1 is unimplemented. The remaining work is review and the release + order recorded in the parent plan (cove → wove → ive / workflow-designer → job-designer). +- Phase 2 has since been built on the same branches; see + [`2026-08-17-Job-Designer-Phase-2-Guided-Designer.md`](2026-08-17-Job-Designer-Phase-2-Guided-Designer.md). + It adds four cove primitives and new ive components to the same release train, so the + order above now matters more, not less: ive's compute redesign will not render against a + cove that predates them. + +## Phase 1 — De-noise (proposals B4, D1, D2, A3) + +Low-risk changes inside the current layout. Ship as one PR train; each item independently +revertable. + +### 1.1 Progressive validation in the compute form (B4) — `@mat3ra/ive` + +- `Compute` renders required-field errors only for touched fields (track touched state per + field) or after an explicit validate call (used by preflight later, exposed as an imperative + `validate()` ref or a `showAllErrors` prop). +- Acceptance: opening the Compute tab of a fresh job shows zero red errors; leaving a required + field empty after focusing it shows exactly one; `showAllErrors` restores today's behavior. + Size: S–M. + +### 1.2 Remove the duplicated Compute sub-tab (D1) — `@mat3ra/workflow-designer` + +- The workflow pane's internal tab strip (Overview / Important settings / Detailed view / + Compute) drops the Compute entry when the host renders its own compute surface. Add a + `hideComputeSubTab` (default false for backward compatibility) prop; job-designer passes true. +- Acceptance: Workflow tab shows exactly one place named "Compute" across the whole designer. + Size: S. + +### 1.3 Humane metadata (D2) — `@mat3ra/wove` + job-designer + +> **Correction (2026-08-16, during implementation):** this was scoped to +> `@mat3ra/workflow-designer`. It is not there. The UUIDs and "idle" chips render in +> **`@mat3ra/wove`** — `components/common/CardHeader`, `components/units/UnitCard`, +> `components/workflows/WorkflowUnitCard` — which draws the subworkflow cards and flowchart +> nodes as MUI `Card`s with the id as `CardHeader` subheader and the status as a `Chip` in the +> title. Found by tracing the rendered DOM in the running job-designer demo. + +- UUIDs on subworkflow cards and flowchart nodes move behind cove's `CopyId`; status chips + render only when the job has been submitted (job-designer passes the flag; the workflow pane + already receives `adjustable={job.isInInitialStatus}`, so thread one more boolean, e.g. + `showUnitStatus`, through `workflow-designer` into wove). +- **Blocked on** the cove release (1.5) — `CopyId` and `StatusChip` must be published first. +- Acceptance: a draft job shows no status chips and no raw UUID text; copy-id copies the id. + Size: M. + +### 1.4 First-class Submit in the header (A3) — job-designer + `@mat3ra/cove` + +- Submit leaves `getDefaultActions()` and becomes a primary header button next to Save + (rendered in both header paths: the injected `EntityHeaderComponent` and the standalone + `EntityHeader` fallback). Disabled state carries a reason string ("compute not configured"); + Terminate replaces it in running states. The dropdown keeps only select-parent and + import-style power actions. +- Mind the known cove `ButtonMultiSelect` mount-snapshot behavior (see the long note in + `Job.jsx#getSaveBtnProps`): read state at click time, never capture the entity at render. +- Terminate is destructive: it gets a confirm step (job name + elapsed time in the dialog), + unlike today's straight dropdown action. +- Acceptance: a draft job with material+workflow+compute set can be submitted in one click from + any tab; the disabled button explains what is missing; Terminate asks before killing a run; + webapp header parity is preserved. Size: M. + +### 1.5 Design-language groundwork (cove) + +- Fix the status palette (contrast + soft variants) and ship `StatusChip` and `CopyId`; + complete the missing `paletteDark` slots (`background`, `text`, `action`, `border`, `icon`, + `unitTypes`). +- Export the job-status semantic mapping (draft / queued / running / finished / error / + terminated → color + icon) from cove; items 1.3 and 1.4 consume it instead of + `text-${job.statusCls}` classes. +- Fix `ButtonMultiSelect` config resync; delete the click-time-read workaround note in + `Job.jsx` once the fixed version is consumed. +- Switch the standalone demo to cove's `DarkMaterialUITheme` (delete `demoTheme` in + `src/standalone/index.tsx`) so the completed dark palette is exercised continuously. +- Acceptance: chips and header render legibly in both themes (visual check in the cove + gallery, cove#92); the demo runs without a private theme; no consumer reads an undefined + dark-palette slot. Size: M–L. + +### 1.6 Visible error state (new; not in the original proposals) + +- `Job.jsx` wraps the designer in `ErrorBoundary` with `fallback={
}` — a render crash + currently produces a silently blank page. Replace with a visible error card (what failed, a + reload affordance, and a copyable error digest); the webapp can inject its reporting hook + through the seam. +- Acceptance: a thrown render error shows the error card in both webapp and standalone; the + fallback never renders an empty page. Size: S. diff --git a/plan/review/2026-08-17-Job-Designer-Phase-2-Guided-Designer.md b/plan/review/2026-08-17-Job-Designer-Phase-2-Guided-Designer.md new file mode 100644 index 0000000..7bdedac --- /dev/null +++ b/plan/review/2026-08-17-Job-Designer-Phase-2-Guided-Designer.md @@ -0,0 +1,262 @@ +# Job Designer Phase 2 — Guided Designer + +- **Ticket:** [SOF-8023](https://mat3ra.atlassian.net/browse/SOF-8023) — Job Designer UX update. +- **Parent:** [../upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md](../upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md) + (overview, current state, cove design-language audit, cross-cutting concerns). +- **Status:** built and on branches — every item below is implemented and in review. + Nothing merged yet, so this document sits in `review/` rather than `implemented/`. +- **Created:** 2026-08-16 · **Updated:** 2026-08-17 + +## Status — what shipped + +| Item | Where | PR | +|------|-------|----| +| 2.1 Readiness rail | job-designer | [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) | +| 2.2 Context strip | job-designer | [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) | +| 2.3 Compute redesign | `@mat3ra/cove` + `@mat3ra/ive` + job-designer | [cove#97](https://github.com/mat3ra/cove/pull/97), [ive#6](https://github.com/mat3ra/ive/pull/6), [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) | +| 2.4 Preflight at submit | job-designer | [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) | +| 2.5 Materials tray and metadata | job-designer | [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) | +| 2.6 Save-state honesty | job-designer | [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) | + +The whole phase is behind one opt-in flag, `useGuidedDesigner` (job-designer) / +`useComputeCards` (ive), defaulted **on** in the standalone demos and **off** for hosts. A +host on the old layout sees no change at all; the demo is where the new one gets reviewed. + +Measured in the standalone demos: a draft with nothing configured reports **4 steps +remaining** and a Submit that names the first of them; filling compute moves it to **Ready +to submit** with an estimate chip reading `64 core·h ≈ $5.12`; the preflight on that job +returns **five passes**; raising the walltime to 24 h turns the Compute step to *over the +12 h queue limit*, disables Submit, and puts the reason in its tooltip; a within-limits +4 × 32 × 12 h job passes the limits check and **fails the budget** at 1536 of 500 core·h, +with "Reduce resources" landing on the Compute step; a warning holds Submit until +acknowledged, then releases it. + +### Divergences from the plan as written + +- **2.3's estimator did not land in ive as a panel-local helper; it is the package's public + arithmetic, and job-designer carries a temporary copy.** The plan put + `estimateComputeCost` "beside the panel". It is instead `@mat3ra/ive`'s + `utils/computeEstimate`, exported, because three surfaces need the same answer — ive's + estimate panel, job-designer's context-strip chip, and the preflight's budget check — + and three implementations of "what does this job cost" would be worse than none. Until + an ive release ships it, job-designer holds an identical copy at `src/computeEstimate.ts` + carrying a `TODO(SOF-8023)` to delete it and import from ive. +- **2.3 is opt-in and additive rather than a rewrite of the compute form.** The cluster, + queue, nodes, cores and walltime fields move to a new surface above the schema form and + are *hidden* in it — not removed — so the ESSE schema still validates them and writes go + through the same `handleFormUpdate` path as a keystroke. Espresso's advanced options are + untouched. The group left holding only two documentation links is retitled + *Documentation*, since calling it "Cluster" would send readers looking for a picker that + moved. +- **2.4 is gated on the same flag as the layout.** The plan did not say. Submit opening a + dialog changes what one click does, so hosts still on the numbered tabs keep today's + one-click submit rather than silently gaining a second step. +- **The preflight distinguishes four outcomes, not three.** Alongside pass / warn / fail + there is **skip**: a check that could not be judged, because the host published no + pricing, no limits or no quota. Most deployments inject none of that, and a green + "Budget" row backed by nothing would be a lie the reader has no way to check. +- **The readiness selector had to learn about cluster limits, and the Submit button had to + stop calling `getSubmitBlockers` itself.** Without it the rail showed Compute *complete* + over a preflight that refused to submit — the exact contradiction the selector exists to + prevent. `getSubmitBlockedReason` was split so the button can be driven from the + readiness report instead of recomputing its own. +- **2.1's `TAB_NAVIGATION_CONFIG` change was not needed.** The plan proposed growing + per-tab step metadata in `@mat3ra/jode`. The rail derives order and labels from the + readiness selector, and reuses the existing tab ids for deep links, so jode is untouched + — one fewer package in the release train. +- **2.2's estimate chip arrived with 2.4, not with the strip.** It needs the estimator, + which is 2.3's; the strip shipped first without it and gained the chip once the + arithmetic existed. +- **The standalone demo needed real data before any of this could be reviewed.** It passed + `clusters={[]}`, so the compute step could not be filled at all, and the job had no `_id`, + so Submit was permanently blocked on "Save the job". Both are fixed in the demo with a + comment saying why. The demo's queue objects also needed `getETAClient()` — ive's queue + table calls it, and a plain object crashes the picker. +- **4 → 5 checks.** The plan listed material, workflow renders, compute limits, and cost vs + quota. A fifth — *Saved* — was added, because "the job has never been saved" was + otherwise reported only by a disabled button. + +### Found by auditing the acceptance criteria against the running app + +Two of 2.1's criteria were recorded as built and were not; both are now fixed (see the +"Built" note under 2.1). A third is a question rather than a defect: + +- **A saved draft cannot change its materials.** `MaterialTab` receives + `addRemoveAllowed={!job.id}`, so the tray's Add and Remove affordances disappear the moment + a draft is saved — while `editable` (which is `status === pre_submission`) stays true, so + the designer simultaneously reports the job as editable. This predates the guided designer, + but the guided designer makes it matter much more: the tray is now the *primary* affordance + for materials, and on any saved draft it is inert. Whether the rule is intended is a product + question — materials may well be baked into the saved job document — so it is recorded here + rather than changed. The rail's "Change Material" affordance is gated on + `isInInitialStatus` instead, so the selector itself remains reachable. +- Consequently **"removing the active material selects a sane neighbour" is unverified in the + demo**: its job carries an `_id` so that Submit is not permanently blocked on "Save the + job", which switches the remove affordance off. The batch copy either side of it is + verified — see below. + +### Still open + +- **Queue-derived limits.** Limits come from `clusterMetadata` per cluster; real queues + differ within a cluster (`nodeLimit`, `maxPPN` per queue). The shape supports it — + `ClusterLimits` would move under the queue — but no host publishes it yet. +- **Same-as-last-job preset.** The plan listed it alongside Debug / Standard / Production. + It needs the reader's previous job, which is host data job-designer does not hold; it + wants an injected `getLastComputeConfiguration()` and is not built. +- **Release ordering.** ive's new components import cove's new primitives, and + job-designer's compute wiring is inert until ive ships. Phase 3 extended the train to + cove → wove → ive / workflow-designer / jove → job-designer; see + [`2026-08-17-Job-Designer-Phase-3-Living-Job.md`](2026-08-17-Job-Designer-Phase-3-Living-Job.md). +- **Webapp-side data.** `clusterMetadata` and `computeQuota` are injected props with demo + values only; nothing in the webapp publishes pricing, limits or quota yet. Until it does, + the estimate shows core-hours alone and the limit and budget checks report *skip*. + +## Phase 2 — Guided designer (proposals A1, A2, B1–B3, C1, E1, E2) + +The layout change. Mockups: `01-guided-designer.html`, `02-compute-cost.html`, +`03-preflight-submit.html`. + +### 2.1 Readiness rail replaces numbered tabs (A1) — job-designer, `@mat3ra/jode` + +- New `JobReadinessRail` component (job-designer) renders lifecycle steps — Material, Workflow, + Compute, Review & Submit (+ Dataset when `workflow.isUsingDataset`; Results, Files after + submission) — each with state (complete / needs attention / empty) and a one-line selection + summary. It replaces `TabsMenu` as the designer's navigation; `currentTab` state machine in + `Job.jsx` stays, only the navigation surface changes. +- Step state derives from a new pure selector module (proposed `src/jobReadiness.ts`): + `getJobReadiness(job, materials) → { steps: [{ id, state, summary }], isSubmittable, + blockingReasons }`. Unit-test this module heavily; it also drives the Submit button and + preflight. It must cover all creation shapes: material jobs, dataset jobs + (`workflow.isUsingDataset`), multi-material sets (`materialsSet` / `isMultiMaterial`), and + parent-derived jobs (parent supplies the material — the Material step reads "from parent + job", not "missing"). +- **The rail spans the whole lifecycle, not just creation.** After submission the creation + steps collapse into read-only summaries and Monitor (later Results, Files) become the active + steps — this replaces today's `defaultTab` status-jumping logic. With `editable={false}` + (shared/public jobs) the rail renders view-only: no "Change" affordances, no Submit. +- `TAB_NAVIGATION_CONFIG` (`@mat3ra/jode`) grows optional per-tab step metadata (order, label) + so webapp and standalone agree on the sequence. +- The three "Select …" dialogs stay as they are, but open from "Change" affordances on their + steps. +- Layout: rail left (fixed ~260 px, collapses to a horizontal stepper under 760 px — the + compact variant can extend cove's existing `StyledStepper`), content right. Step state + renders with cove `StatusChip` colors from the job-status mapping (1.5). Keep the DOM of tab + panels unchanged where possible so Cypress selectors survive. +- Acceptance: a new user can create and submit a job without opening any dropdown; deep links + via `getRouteQueryTab` still land on the right step; a finished job opens on Monitor/Results + with creation steps summarized; the rail is keyboard-navigable (arrow keys between steps, + visible focus, `aria-current` on the active step). Size: L. +- **Built** — `src/jobReadiness.ts` (16 unit tests) and `src/components/JobReadinessRail.tsx`. + Two criteria were initially missed and fixed after an audit against the running app: each + step that owns a "Select …" dialog now carries a **Change** (or **Choose**) affordance, so + the headline acceptance — creating a job without opening the dropdown — actually holds; and + the rail **collapses to a horizontal scrolling strip below the md breakpoint** rather than + stacking full-width rows that pushed the step's own content off a narrow screen. Adding the + affordances exposed a crash (`object is not iterable`): the package's dialog types describe + `{ isOpen, open, close }` while `Job.jsx` destructures `[open, close]`, and only the + never-clicked dropdown had ever reached them. `normalizeDialogHandle` accepts either. + `@mat3ra/jode` was left alone: the rail takes its order and labels from the selector and + reuses the existing tab ids, so deep links keep working without new schema. The selector + also reads host-published cluster limits, so the Compute step says *over the 12 h queue + limit* rather than showing a green tick over a preflight that would refuse. + +### 2.2 Context strip (A2) — job-designer + +- New `JobContextStrip` under the header: chips for material (formula · source), workflow + (name · subworkflow/unit counts), compute (cluster · nodes×cores · walltime), estimate + (core-hours ≈ cost). Chips navigate to their step; incomplete chips render in the attention + style. Data comes from the same `getJobReadiness` selector plus the estimate helper (2.3). +- The parent job moves here too: today it renders as a dismissable `Alert` above the tabs + (`Job.jsx#renderParentJob`); it becomes a context chip (parent name · project) with the + remove affordance in its popover — same `setParent` / `unsetParent` model calls (the flow + fixed in SOF-7962). +- Acceptance: on every step, the other selections stay visible; clicking a chip switches step; + a parent-derived job shows the parent chip and no orphaned Alert. Size: S–M. +- **Built** — `src/components/JobContextStrip.tsx`. The estimate chip arrived later, with + 2.3's estimator; the strip shipped first without it rather than showing a placeholder. + +### 2.3 Compute redesign: cluster cards, presets, live estimate (B1–B3) — `@mat3ra/ive` + +- Cluster picker becomes selectable cards — cove `SelectableCard` with a `StatusChip` queue + badge — fed by the existing `clusters` prop; job-designer passes an optional + `clusterMetadata` enrichment (pricing, limits, queue wait) injected by the webapp through + `setDependencies()` — standalone falls back to static demo data. Nodes / cores / walltime + become cove `NumericStepperInput`s with min/max from `clusterMetadata`. +- New `ComputeEstimatePanel` built from cove `MetricTile`s and a `SegmentedMeter` for quota: + core-hours = nodes × cores × walltime, price, queue ETA, monthly quota. Pure function + `estimateComputeCost(computeConfiguration, clusterMetadata)` lives beside the panel and is + unit-tested. +- Presets row (Debug / Standard / Production / same-as-last-job) writes through the normal + `onUpdate(compute)` path so undo/save semantics are untouched. +- Validation limits (max nodes, cores per node, queue walltime caps) come from + `clusterMetadata`; violations render inline (not red-on-first-paint — 1.1's touched logic). +- The advanced-options section (`showAdvancedComputeOptions`, gated today by the applications' + `hasAdvancedComputeOptions` in `Job.jsx`) is preserved as a collapsed "Advanced" group below + the cards — redesign must not drop the espresso-class options. +- Acceptance: changing any field updates the estimate synchronously; exceeding a limit flags + the field and the estimate panel; presets fill the form in one click; advanced options remain + reachable; webapp data path and standalone fallback both render. Size: L. +- **Built** — four new cove primitives (`SelectableCard`, `MetricTile`, `SegmentedMeter`, + `NumericStepperInput`, with their bound arithmetic and meter geometry unit-tested), then + `ClusterCards`, `ComputeResources` and `ComputeEstimatePanel` in ive behind + `useComputeCards`. Presets are clamped to the cluster's limits, so the button that exists + to avoid an invalid configuration cannot produce one. Open question 1 is still open — + nothing publishes pricing or quota — so the panel degrades per tile rather than waiting on + it. + +### 2.4 Preflight at submit (C1) — job-designer (+ webapp data) + +- New `PreflightDialog` opened by Submit: runs ordered checks — material set, workflow renders + (`job.render()` succeeds / template errors empty), compute within `clusterMetadata` limits, + estimated cost vs. remaining quota. Each check row: pass / warn / fail; fails deep-link to + the owning step; warns require acknowledge. Submit proceeds only with zero fails and all + warns acknowledged, then calls the existing `onSubmit` prop. +- Check implementations live in `src/preflight/` as pure async functions + `runPreflightChecks(job, materials, clusterMetadata, quota) → PreflightReport`, injectable so + the webapp can add checks (e.g. balance) via `setDependencies()`. +- Acceptance: submitting an incomplete job is impossible through the UI; every fail row's + action lands on the field that fixes it; checks are unit-tested including the warn/ack flow. + Size: M–L. +- **Built** — `src/preflight/` (five checks, a runner, 20 unit tests) and + `src/components/PreflightDialog.tsx`. Hosts append their own checks through + `setDependencies({ preflightChecks })`. A check that throws yields a *skip* row rather than + blocking submission: our own bug must not stand between a reader and their job. + +### 2.5 Materials tray and metadata (E1, E2) — job-designer (+ viewer package) + +- `MaterialTab` gains a chips tray above the viewer (add / remove / switch — reusing + `onUpdateIndex`, `onMaterialRemove`, `openAddMaterialsDialog`) and the explicit copy + "N materials → the workflow runs N times". Multi-material switching stops hiding inside the + Workflow tab (the workflow pane's switcher stays for parity but the tray is the primary + affordance). +- Metadata side panel (formula, lattice, space group, atom count, source id) rendered from the + `Material` model next to the injected viewer; the viewer component API is unchanged. +- Acceptance: adding a second material updates the tray, the batch copy, and the context strip; + removing the active material selects a sane neighbor. Size: M. +- **Verified in the demo** with its new 1-vs-3 materials toggle: three chips in the tray over + "3 materials — the workflow runs 3 times, once per material.", with the rail step and the + context chip both reading "3 materials — runs 3 times". The removal half could not be + exercised — see the audit note above. +- **Built** — [job-designer#19](https://github.com/mat3ra/job-designer/pull/19). Divergence: + the metadata is read through a defensive `getMaterialSummary()` that omits any field it + cannot read, because `MaterialTab` already renders a fallback for hosts passing a plain + config and made's model getters throw on partial data. Space group is shown only when the + model actually carries a `symmetry` derived property — standata materials generally do not, + so the row is usually absent rather than guessed from the name. + +### 2.6 Save-state honesty (new; relates to UX-498) + +- The header states the truth about persistence: "Saved" only after the entity actually + persisted through `onSave` / the `shouldPersistJobOnUpdate` pipeline, "Unsaved changes" + otherwise, and a leave-guard (browser `beforeunload` + router guard injected via the seam) + when a dirty draft is about to be abandoned. The mockups' "All changes saved" copy is the + target state; showing it without it being true would be worse than today. +- Explicit non-goal here: no new autosave backend — this item only surfaces existing state + honestly. If product wants real autosave (UX-498 direction), that is a separate ticket. +- Acceptance: editing any field flips the indicator to dirty; Save flips it back; closing the + tab with a dirty draft warns; the indicator never claims "Saved" while in-memory state + differs from the persisted entity. Size: S–M. +- **Built** — [job-designer#19](https://github.com/mat3ra/job-designer/pull/19). Dirty state is + marked in the mutating handlers rather than in `persistJob()`, which also runs on mount and + on entering the Workflow tab; and both save paths were routed through a single `saveJob()` + so the flag cannot be cleared by one header and missed by the other. diff --git a/plan/review/2026-08-17-Job-Designer-Phase-3-Living-Job.md b/plan/review/2026-08-17-Job-Designer-Phase-3-Living-Job.md new file mode 100644 index 0000000..176f047 --- /dev/null +++ b/plan/review/2026-08-17-Job-Designer-Phase-3-Living-Job.md @@ -0,0 +1,123 @@ +# Job Designer Phase 3 — The Living Job + +- **Ticket:** [SOF-8023](https://mat3ra.atlassian.net/browse/SOF-8023) — Job Designer UX update. +- **Parent:** [../upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md](../upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md) + (overview, current state, cove design-language audit, cross-cutting concerns). +- **Status:** built and on branches — every item below is implemented and in review. + Nothing merged yet, so this document sits in `review/` rather than `implemented/`. +- **Created:** 2026-08-16 · **Updated:** 2026-08-17 + +## Status — what shipped + +| Item | Where | PR / branch | +|------|-------|----| +| 3.1 Lifecycle header | `@mat3ra/cove` + job-designer | [cove#97](https://github.com/mat3ra/cove/pull/97), [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) | +| 3.2 Run monitor | `@mat3ra/jove` + `@mat3ra/cove` + job-designer | `mat3ra/jove@feature/SOF-8023-run-monitor`, [cove#97](https://github.com/mat3ra/cove/pull/97), [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) | +| 3.3 Unit inspector + theme parity | `@mat3ra/workflow-designer` + `@mat3ra/wove` + job-designer | [workflow-designer#13](https://github.com/mat3ra/workflow-designer/pull/13), [wove#11](https://github.com/mat3ra/wove/pull/11), [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) | + +Everything is behind the same opt-in flags as phase 2 — `useGuidedDesigner`, +`useUnitInspector`, `useHostTheme`, `showRunMonitor` — defaulted off for hosts and on in the +standalone demos. + +Measured in the running demos: a draft's header reads +`Draft[current] → Queued[upcoming] → Running[upcoming] → Finished[upcoming]`, and a running +job's `Draft[done] → Queued[done] → Running[current]`, with the rail swapping *Review & +submit* for *Monitor · Running* and *Files* and the Submit button disappearing. jove's +simulated run reports `1/5 finished`, `22s elapsed`, a unit at `10s so far`, and a log tail +that grows. Clicking a flowchart unit opens a drawer titled *cp · Unit 1 · execution* +carrying that unit's dynamics parameters. Under a dark host the flowchart pane is now +`#0d1117` with white control glyphs and visible grid dots — previously white, black-on-white, +and pure black. + +### Divergences from the plan as written + +- **3.3's drawer is not built on cove's `ResizableDrawer`.** That component is anchored to + the bottom and resizes on height only — its hook takes a `minHeight`, its buttons are up + and down arrows. Generalising it to two axes is a change to a shared component with its + own consumers; the width handle in `UnitInspectorDrawer` is a few lines and puts none of + them at risk. +- **The white canvas was not mainly the flowchart's colours.** Fixing those was necessary + but not sufficient: `WorkflowDefaultLayout` pinned the *entire designer* to + `oldLightMaterialUITheme`, so the subtree was light no matter what the shell did. The real + fix is `useHostTheme`, which skips that override. The flowchart's own hard-coded colours + are fixed too — including a background-dot colour set to the literal string `"000"`, not a + valid CSS colour, which fell back to reactflow's light default and vanished on dark. +- **3.1 dropped the status tint rather than keeping it alongside.** The plan said "replace"; + the timeline states the status better than a tinted glyph, and two statements of the same + fact in one header is one too many. The WorkflowTab's own `iconCls` is a separate surface + and is untouched. +- **The lifecycle timeline distinguishes five stage states, not four.** Beyond done / + current / upcoming there is **failed** — the last stage becomes the failure itself, named + for what happened ("Terminated", "Timed out"), not a finish that never came — and + **skipped**, for stages the job never reached. "Upcoming" on a terminated job would + suggest it might still run. +- **3.2's log viewer is a new cove primitive.** The plan named a `LogViewer` without saying + where it lived; it is in cove, per the cross-cutting rule that new primitives land there + first. It follows the tail until the reader scrolls up, then stops and offers to resume. +- **3.2's navigation fires on the status transition, not on the submit click.** Switching at + the click would land the reader on a Results tab that `conditionalTabsMap` has not enabled + yet, because the job is still `pre-submission` until the server says otherwise. +- **The convergence chart was not rebuilt.** `ConvergenceChart` already exists in jove and + renders from job properties; the monitor shows units, durations and the log, and leaves the + chart where it is. Restyling it with cove tokens is not done. + +### Still open + +- **The webapp data adapter — half of 3.2, as the plan predicted.** Nothing publishes a log + tail. `getJobLogTail` is read from `setDependencies()`; without it the monitor says the + deployment provides no log feed rather than showing an empty box that reads as a silent + job. Unit status tracks come from the job document and need no new endpoint. +- **Per-unit progress within a unit.** The monitor reports which units are running and for + how long, not how far through its own iterations a unit is. That needs the convergence + stream, which is the same data the chart uses. +- **Release ordering, now four deep.** cove → wove → ive / workflow-designer / jove → + job-designer. jove is newly in the train: `RunMonitor` imports cove's `LogViewer`, + `MetricTile` and `SegmentedMeter`. +- **Two pre-existing breakages were fixed in passing** and are worth knowing about: + workflow-designer's unit tests never ran (16/16 failed on `main` — the tests import the + package by its own name and nothing resolved it; one `tsconfig` `paths` entry fixes it), + and jove's `npm run lint` failed on a prettier violation predating this work. + +## Phase 3 — The living job (proposals F1, F2, C2, D3, D4) + +Mockup: `04-run-monitor.html`. Mostly lands in dependency packages; job-designer wires props. + +### 3.1 Lifecycle header (F2) — `@mat3ra/cove` + job-designer + +- Replace the status-colored icon (`iconCls: text-${job.statusCls}`) with cove's + `LifecycleTimeline` (Draft → Queued → Running → Finished/Error, timestamps on hover), + colored by the job-status mapping from 1.5. Rendered by the header; state derives from + existing job status fields. Size: S–M. +- **Built** — cove's `LifecycleTimeline` / `JobLifecycleTimeline` (pure `getLifecycleStages`, + 15 unit tests) rendered by both of job-designer's header paths. + +### 3.2 Run monitor (F1, C2) — `@mat3ra/jove` + webapp + +- `ResultsTab` grows a monitor mode while the job is active: per-unit list with `StatusChip` + states, durations, and progress; a cove `LogViewer` tail; convergence chart streaming from + the existing property update channel (`onOutputUpdateRequest` / job properties refresh), + drawn with cove tokens (single series, recessive grid, emphasized endpoint). On finish it + settles into today's results view with a `MetricTile` summary strip. +- After a successful preflight submit (2.4), the designer navigates to the monitor instead of + staying on the editing view (C2). +- Data contract needs webapp work (log tail endpoint or polling adapter injected via + `setDependencies()`); standalone ships a simulated feed for the demo, mirroring the mockup. + Size: L–XL (the largest single item; the webapp data adapter is half of it). +- **Built** — jove's `src/runMonitor.ts` (25 unit tests) and `RunMonitor`, behind + `ResultsTab`'s `showRunMonitor`; cove's `LogViewer`; job-designer navigates on the status + transition and feeds units and the log through. The webapp adapter is still missing, as the + plan expected, and the monitor says so rather than implying a silent job. + +### 3.3 Unit inspector drawer (D3) and theme parity (D4) — `@mat3ra/workflow-designer` + +- Clicking a flowchart node opens a right-side drawer — built on cove's existing + `ResizableDrawer` — with that unit's important settings (replacing the Overview / Important + settings / Detailed view bounce). Keep the old sub-tabs behind a prop until the webapp + migrates. +- Flowchart pane colors move to the CSS custom properties exported by cove's `ThemeProvider` + (design-language section) so the dark shell stops framing a white canvas; `unitTypes.*` + colors come from the completed dark palette. +- Size: M–L. +- **Built** — workflow-designer's `UnitInspectorDrawer` behind `useUnitInspector`, and + `useHostTheme` to stop the designer pinning itself to a light theme; wove's flowchart takes + its pane, control, edge and grid colours from the theme. diff --git a/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md b/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md new file mode 100644 index 0000000..9967f8f --- /dev/null +++ b/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md @@ -0,0 +1,289 @@ +# SOF-8023 — Job Designer: Guided Designer Implementation Plan + +**Ticket:** [SOF-8023](https://mat3ra.atlassian.net/browse/SOF-8023) — Job Designer UX update: +guided designer flow. Per-phase implementation tickets can be filed under it when work starts. +**Source proposals:** [`UIUX_IMPROVEMENTS.md`](../../UIUX_IMPROVEMENTS.md) (proposals A1–F2) and +the interactive mockups in [`mockups/`](../../mockups/). +**Branch of record for the brainstorm:** `claude/jd-ui-ux-improvements-99w9fg`. +**Prior art:** SOF-7978 extracted the designer into standalone packages; SOF-7991 tracks the +dependency-injection cleanup this plan must not regress. +**Parallel effort:** [SOF-8024](https://mat3ra.atlassian.net/browse/SOF-8024) is the same +exercise for the *workflow* designer ([workflow-designer#12](https://github.com/mat3ra/workflow-designer/pull/12), +six portion documents in that repo's `plan/`). The two overlap in three places, so treat its +documents as authoritative for those: its **portion 2** covers the cove design language this +plan's §"Design language" opens (cove#97 implements the semantic and dark-palette half); its +**portion 1 items 1.1/1.5** are this plan's item 1.3; and its **portion 3** covers the +`@mat3ra/ive` compute form that phase 2.3 rebuilds. Coordinate before starting any of those. + +## Summary + +Turn the Job Designer from a five-tab filing cabinet into a guided flow: a readiness rail that +shows what a job still needs, a compute step that answers "what will this cost", a preflight +check at submit, and a live monitor after it. Work is split into three phases so that phase 1 +ships inside the current layout with no design sign-off, phase 2 introduces the new layout, and +phase 3 closes the post-submission loop. + +**All three phases are built and in review** (see the per-phase documents in `plan/review/`). +What remains in this document is the material that outlives them: the grounding, the cove +audit, the cross-cutting rules, the release order and the open questions — this overview moves +last, once the phases it summarises have shipped. + +## Current state (grounding) + +The designer shell is `src/components/Job.jsx` (class component, mixins from `mixwith`): + +- Tabs come from `TAB_NAVIGATION_CONFIG` (`@mat3ra/jode`) rendered by cove's `TabsMenu` + (`variant="fullWidth" centered`); conditional visibility via `conditionalTabsMap`. +- All creation actions live in the "Select Job Actions" dropdown built by + `Job.jsx#getDefaultActions` (select materials / workflow / parent / dataset, Submit, + Terminate). +- Tab content wraps injected packages: + `ComputeTab` → `Compute` from `@mat3ra/ive`; `WorkflowTab` → `Workflow` from + `@mat3ra/workflow-designer`; `ResultsTab` from `@mat3ra/jove`; `MaterialTab` renders an + injectable `MaterialViewerComponent`; `FilesTab` renders an injected + `FilesExplorerContainer`. +- The webapp swaps in its own header and dialogs through `setDependencies()` + (`src/setDependencies.ts`) and `JobDesignerContext`; the standalone demo + (`src/standalone/index.tsx`, `npm run dev`, port 3003) exercises the package-native + fallbacks with standata workflows/materials. + +Pain points, verified in the running standalone app (screenshots: [`plan/context/images/`](../context/images/) +and the *Job Designer Next* artifact): the creation path is hidden in the dropdown; numbered +tabs carry no progress or cross-tab context; the compute form shows four required-field errors +on first paint and no cost estimate; the workflow tab duplicates a "Compute" sub-tab, prints +UUIDs and "idle" chips at design time, and renders a light flowchart in the dark shell; there +is no preflight at submit and no live view after it. + +## Design language (cove): audit and required work + +The platform design language lives in `@mat3ra/cove` (`dist/theme`, `dist/mui`, +`dist/mui-composed`). Audit of the shipped package (2026.7.28-0): + +### What exists and is reusable + +- Theme pair `LightMaterialUITheme` / `DarkMaterialUITheme`, plus `commonSettings` (Roboto and + monospace font stacks, button size scale, breakpoints). The default export is still + `oldLightMaterialUITheme`, which is what the webapp consumes today. +- Palette slots beyond stock MUI: `unitTypes` (execution / condition / assignment / assertion), + `border`, `icon` — already used by workflow surfaces. +- Primitives the guided designer can reuse directly: `TabsMenu`, `StyledStepper` (linear, + label-only), `LinearProgress`, dialogs, `Dropdown` / `NestedDropdown`, `RadioGroup`, + `IconByName` (~370-entry `entities.* / actions.* / shapes.*` map), `ResizableDrawer`, + `AlertProvider`, and the composed `EntityHeader` / `EntityName` / loading components. + +### Defects to fix first (cove PRs; prerequisites for the phases below) + +- **`paletteDark` is skeletal**: only `primary`, `secondary`, and the four status colors — no + `background`, `text`, `action`, `border`, `icon`, or `unitTypes` slots. (Worth stating + precisely, because the parallel SOF-8024 audit records it as "light and dark are identical": + they are not. MUI backfills its own standard slots, so dark *looks* like stock-MUI dark, but + the custom slots resolve to `undefined` — which is exactly why wove cannot read + `unitTypes.*` in dark mode.) Anything reading + `theme.palette.border.main` or `unitTypes.*` in dark mode gets `undefined`. This is the root + of the light-flowchart-in-dark-shell problem (D4) and the reason the standalone demo rolls + its own `createTheme` (`src/standalone/index.tsx` `demoTheme`). +- **Status colors are not accessible as shipped**: `success.main` `#72E128` (neon green) + declares `contrastText #FFFFFF` (~1.6:1 contrast); `error.contrastText` is + `rgba(0, 0, 0, 0.23)` — 23%-alpha black on red. Chips or badges built on these are + unreadable. Re-derive the four statuses with passing contrast and add "soft" surface variants + (tinted background + strong foreground) for chips, badges, and flag rows — the pattern every + mockup uses. +- **No job-status semantic mapping**: draft / queued / running / finished / error / terminated + → color + icon currently lives ad hoc in consumers (`iconCls: text-${job.statusCls}`). + Define the mapping once in cove so the header chip, readiness rail, unit pills, and monitor + agree. +- **`ButtonMultiSelect` snapshots `buttonConfigs` on mount** and never resyncs (workaround + documented at length in `Job.jsx#getSaveBtnProps`). Fix the resync in cove, then delete the + workaround. + +### New primitives needed (cove additions; each small and generic) + +| Primitive | Used by | Phase | +|-----------|---------|-------| +| `StatusChip` — icon + label pill in status-soft colors | lifecycle header, rail, unit list, queue badges | 1, 3 | +| `CopyId` — truncated id behind a copy icon + tooltip | D2 | 1 | +| `SelectableCard` — radio-behavior card | cluster picker (B1), presets (B3) | 2 | +| `MetricTile` — label + value + unit, tabular numerals | compute summary, estimate panel, results strip | 2, 3 | +| `SegmentedMeter` — used + this-job + remaining | budget/quota display (B2) | 2 | +| `NumericStepperInput` — − value +, min/max, unit suffix | nodes / cores / walltime (B1) | 2 | +| `LifecycleTimeline` — phases with timestamps and states | F2, monitor header | 3 | +| `LogViewer` — monospace, tail-follow, filterable | monitor (F1) | 3 | + +### Theme adoption and token bridge + +- Promote the current theme pair to the default (deprecate `oldLightMaterialUITheme` behind an + explicit import), complete `paletteDark`, and switch the standalone demo to + `DarkMaterialUITheme`, deleting its private `demoTheme` — the demo becomes the dogfood + surface for the dark palette. +- Export the palette as CSS custom properties from cove's `ThemeProvider` (e.g. + `--m3-surface`, `--m3-border`, `--m3-status-running`) so non-MUI surfaces — the flowchart + canvas and unit nodes — consume the same tokens (D4) without importing MUI at render time. +- Typography for data: estimate, logs, ids, and durations use `commonSettings.fonts.monospace` + and `font-variant-numeric: tabular-nums`; codify this inside the new primitives instead of + per-app CSS. + +## Goals + +1. A new job is created left-to-right with visible progress; nothing required hides in a menu. +2. Compute answers cost, limits, and queue wait before submission. +3. Submit runs visible checks; failures deep-link to the fix. +4. A submitted job has a live monitor; the designer stops pretending the job is still a draft. +5. The `setDependencies()` / `JobDesignerContext` injection seam survives every change — the + webapp must keep working with its own header, dialogs, and file explorer. + +## Non-goals + +- No redesign of the workflow editing experience itself (unit graph editing, subworkflow + composition) — that is UX-500 territory. +- No changes to job submission backend contracts; preflight consumes existing data. +- No visual rebrand: the work completes and repairs the existing cove theme system (see the + design-language section) rather than introducing a new one; the mockups' exact palette is + illustrative. + +## Phase 1 — De-noise (moved) + +Built and in review. Its items, what actually shipped, and the divergences from this plan +are recorded in +[`../review/2026-08-16-Job-Designer-Phase-1-De-noise.md`](../review/2026-08-16-Job-Designer-Phase-1-De-noise.md). + +Phases are split across documents so each can move through `plan/` independently, as the +folder's own convention asks — this overview moves last. +## Phase 2 — Guided designer (moved) + +Built and in review. Its items, what actually shipped, and the divergences from this plan +are recorded in +[`../review/2026-08-17-Job-Designer-Phase-2-Guided-Designer.md`](../review/2026-08-17-Job-Designer-Phase-2-Guided-Designer.md). +## Phase 3 — The living job (moved) + +Built and in review. Its items, what actually shipped, and the divergences from this plan +are recorded in +[`../review/2026-08-17-Job-Designer-Phase-3-Living-Job.md`](../review/2026-08-17-Job-Designer-Phase-3-Living-Job.md). + +## Cross-cutting + +- **Injection seam:** every new data need (cluster metadata, quota, log tail, extra preflight + checks) enters through `setDependencies()` / `JobDesignerContext` with a package-native + fallback, exactly like `EntityHeaderComponent` and `FilesExplorerContainer` today. No Meteor + or Redux imports in package code (SOF-7991 direction). +- **Feature flag:** phase 2's layout ships behind a `useGuidedDesigner` flag (webapp-injected + boolean, default off) so webapp and standalone can flip independently; the legacy tabs path + remains until parity is verified, then is removed in a cleanup PR. +- **Design language:** all new UI is composed from the cove primitives listed in the + design-language section — no app-local colors or one-off chips; job statuses always come + from the cove job-status mapping. New primitives land in cove with gallery entries + (cove#92 pattern) before consumers use them. +- **Naming:** per `AGENTS.md` — full descriptive names (`JobReadinessRail`, + `ComputeEstimatePanel`, `runPreflightChecks`), PascalCase components, no abbreviations. +- **Standalone demo as testbed:** every phase must be demonstrable in `npm run dev` with + standata content and stub metadata; the demo is the review surface for design sign-off. +- **Performance guardrail:** `Job.jsx` has delicate update machinery — `shouldComponentUpdate` + mixins, `renderGeneration`, and `persistJob()` runs `job.render()` (template rendering) on + workflow-tab entry. Readiness and estimate recomputation must be memoized pure derivation + that never calls `persistJob` or bumps `renderGeneration`; a compute keystroke must not + trigger a workflow re-render. Add a regression test that counts `job.render()` calls during + compute edits. **Done** — `tests/renderGuardrail.tests.ts` counts the calls: + readiness, the estimate and the submit blockers stay at zero across a burst of + walltime keystrokes, and a whole preflight report renders exactly once. +- **Accessibility:** rail and dialogs are keyboard-operable (focus trap in `PreflightDialog`, + `aria-current` on the active step, visible focus states); status is never conveyed by color + alone (icons + labels on every `StatusChip`); the palette work in 1.5 fixes the contrast + side. +- **Localization:** the webapp localizes via TAPi18n (stubbed as `createMessageTextTAPi18n` in + `Job.jsx`); new user-facing strings (readiness summaries, preflight messages, save-state + copy) go through an injectable message resolver on the seam with English fallbacks — no + hard-coded strings scattered through components. **Done** — `src/messages.ts` holds ~70 + keys with English defaults and named interpolation; hosts inject + `setDependencies({ translate })`, and fallback is per key so a partial translation still + reads as sentences. Component *chrome* outside the phase 2–3 surfaces (legacy tab labels, + the error card, dropdown actions) is not migrated — those are static strings a scanner can + lift, where the derived prose here could not have been. +- **Shared with the Materials Designer effort:** the cove primitives and palette repairs in + 1.5 serve the parallel materials-designer UX update too — keep primitive APIs generic (no + job-specific props) and land cove work first so both designers consume the same release. + +### Release sequencing + +Additive props with safe defaults everywhere, so no lockstep release is required. Order: + +1. `cove` (1.5, palette + primitives) — everything else consumes it. +2. `wove` (1.3, 3.3 flowchart theming) — consumed by `workflow-designer`. +3. `ive` (1.1, 2.3), `workflow-designer` (1.2, 1.3, 3.3), `jove` (3.1 consumer, 3.2) — in + parallel, each behind default-off props. All three now import cove primitives that do not + exist in the published package, so step 1 is a hard prerequisite, not a preference. +4. ~~`jode` (2.1 step metadata)~~ — not needed; the rail derives its sequence from the + readiness selector and reuses the existing tab ids. jode is untouched. +5. `job-designer` — version bumps + the shell work (1.4, 1.6, 2.1, 2.2, 2.4, 2.5, 2.6), + verified in the standalone demo, plus the phase-3 wiring (3.1, 3.2, 3.3). Its + `src/computeEstimate.ts` is a stand-in for ive's canonical estimator and is deleted at + this step, once ive ships one. +6. `web-app` — pin bumps, seam wiring (`registerDependencies`), flag flip after the Cypress + suite is green. + +## Success metrics + +Instrument before flipping the `useGuidedDesigner` flag so there is a baseline. Events go +through an analytics hook injected via the seam (no-op in standalone). + +**Built** — `src/analytics.ts` declares the event set and the designer emits it; a host +supplies `setDependencies({ trackEvent })`. A recorder that throws is swallowed: analytics +must never be what stops somebody submitting a job. What is still needed is the webapp's +recorder and a dashboard — the events exist, nothing is collecting them yet. + +- **Time to first submit** for a new job (open designer → successful submit) — the headline + number the redesign should move. +- **Preflight outcomes**: fail/warn rates per check, and how often a deep-link fix is used — + high fail rates identify which step's affordances still fail users. +- **Abandonment**: drafts opened vs. submitted, and the step where users leave. +- **Terminate-after-submit within N minutes** — a proxy for "submitted with wrong settings", + which the estimate + preflight should reduce. +- **Monitor engagement** (phase 3): share of running jobs whose owners watch the monitor vs. + navigate away. + +## Testing + +- Unit (node `--test`, `tests/`): `getJobReadiness`, `estimateComputeCost`, + `runPreflightChecks`, preset application, touched-state validation reducer. +- Component/e2e (Cypress, `tests/e2e`): create-job happy path through the rail; submit blocked + by a failing preflight then fixed; context-strip navigation; multi-material tray; monitor + simulation smoke test. **Written** — `guided_designer.feature` covers all of it. The suite + it replaces tested a placeholder app that no longer exists and pointed at + workflow-designer's port. Cypress is not installed in the authoring environment, so the run + itself is unverified; all 30 selectors were resolved against the live demo instead. +- Regression: webapp integration run (`web-app` Cypress UI suite) before flipping the flag, + since the webapp injects its own header/dialogs through the seam this plan touches. + +## Risks + +| Risk | Mitigation | +|------|------------| +| Cluster pricing/limits/queue data may not exist as a clean API | Estimate panel degrades: hide cost/ETA rows when metadata is absent; limits fall back to none | +| `ButtonMultiSelect` mount-snapshot bug class (stale closures in header buttons) | **Done** — fixed in cove (tracks the selected *id*, resolves against the live prop) and pinned by `cove/tests/selectedOption.tests.ts`. `Job.jsx`'s read-at-click-time workaround can come out once cove ships | +| Rail layout breaks webapp deep links (`getRouteQueryTab`) | Keep tab ids stable; rail maps ids 1:1 to today's `TAB_NAVIGATION_CONFIG` | +| Cross-repo sequencing (ive / workflow-designer / cove / jove versions) | Land package changes behind additive props with safe defaults; bump versions in job-designer last | +| Dataset-driven jobs (`isUsingDataset`) diverge from the material path | Rail renders a Dataset step in place of Material; readiness selector covers both branches | +| Fixing the status palette recolors screens already shipped on the old values | Land palette fixes as a dedicated cove PR with before/after gallery screenshots and design review; consumers pick the bump explicitly | +| Completing `paletteDark` changes dark-mode rendering of existing cove consumers | Additive slots only (no changes to existing light values); verify against the cove gallery and the standalone demo before releasing | + +## Open questions + +1. Where do pricing and quota live today — is there an existing accounting endpoint the webapp + can inject, or is this new backend work? **Still open, and no longer blocking:** 2.3 and + 2.4 shipped with `clusterMetadata` / `computeQuota` as injected props, demo values in the + standalone apps, and per-tile degradation — core-hours from the job alone, cost only with a + published price, and a preflight row that reports *skip* rather than passing on no + evidence. The question is now what the webapp injects, not whether the UI can wait for it. +2. Should preflight warnings (e.g. convergence sanity) come from workflow model metadata or + stay host-injected only? Package-native heuristics risk false alarms. +3. Does the monitor (3.2) poll job properties or can the webapp provide a push channel? Polling + is acceptable for v1. +4. File per-phase SOF tickets under SOF-8023 — one per phase, or one per proposal group? +5. Should a **saved draft** be able to change its materials? `MaterialTab`'s + `addRemoveAllowed={!job.id}` says no, while `editable` says the job is still editable. The + guided designer makes the contradiction visible, because the materials tray is now the + primary affordance and is inert on any saved draft. Pre-existing; not changed here. + (Suggest one per phase.) +5. Which analytics channel should the success-metrics events use — the webapp's existing + telemetry, or is this the moment to add a product-analytics hook to the seam? (Gates the + baseline capture, not the UI work.) +6. Localization: is English-only acceptable for the new strings at launch (matching the + packages' current state), with the message-resolver seam making them translatable later? diff --git a/src/JobDesignerContext.tsx b/src/JobDesignerContext.tsx index 6dfdf8a..db2ee3a 100644 --- a/src/JobDesignerContext.tsx +++ b/src/JobDesignerContext.tsx @@ -37,6 +37,18 @@ export interface JobDesignerDeps { useReduxDialog: (dialogType: string) => JobDesignerDialogTuple; /** Optional Files explorer component. In standalone, renders nothing. */ FilesExplorerContainer?: React.ComponentType; + /** + * Resolves a message key to localized copy (the webapp wraps TAPi18n). + * Return undefined for a key you have no translation for — the designer + * falls back to its English default per key, never shows the key itself. + * See `src/messages.ts` for the catalogue. + */ + translate?: (key: string, params?: Record) => string | undefined; + /** + * Records a product event. No-op when absent — see `src/analytics.ts` for the + * events the guided designer emits and why each one is worth having. + */ + trackEvent?: (event: string, properties?: Record) => void; /** * Optional full-featured page header (the webapp's EntityHeader organism: description * toggle/editor, Save & Exit split button, dropdown). When absent, Job renders a minimal diff --git a/src/analytics.ts b/src/analytics.ts new file mode 100644 index 0000000..930d39c --- /dev/null +++ b/src/analytics.ts @@ -0,0 +1,102 @@ +import { getInjectedDeps } from "./setDependencies"; + +/** + * The events the guided designer emits, and why each one is worth having. + * + * The plan asks for a baseline before the `useGuidedDesigner` flag flips — + * otherwise "did the redesign help?" is answerable only by opinion. These are + * the measurements that would settle it, and they are declared here rather than + * scattered as string literals so the set can be read in one go and a host can + * see exactly what it is being asked to record. + * + * No-op without a host recorder. Nothing here reaches the network on its own, + * and the standalone demo emits into the void. + */ + +export const ANALYTICS_EVENTS = { + /** + * A designer was opened on a draft. Paired with `jobSubmitted` this gives + * **time to first submit**, the headline number the redesign should move. + */ + designerOpened: "job_designer.opened", + /** A draft was submitted. Carries the seconds since the designer opened. */ + jobSubmitted: "job_designer.submitted", + /** + * The preflight finished a run. Carries per-outcome counts and which checks + * failed — a check that fails often names a step whose affordances still do + * not work, which is more actionable than the submit rate alone. + */ + preflightCompleted: "job_designer.preflight_completed", + /** + * A reader followed a preflight row's fix to its step. Low usage against a + * high fail rate means the deep link is not being found. + */ + preflightFixFollowed: "job_designer.preflight_fix_followed", + /** A warning was acknowledged rather than acted on. */ + preflightWarningAcknowledged: "job_designer.preflight_warning_acknowledged", + /** + * The reader moved between steps. The step a session ends on is where the + * **abandonment** happens. + */ + stepSelected: "job_designer.step_selected", + /** + * A job was terminated soon after being submitted — a proxy for "submitted + * with the wrong settings", which the estimate and preflight should reduce. + */ + jobTerminated: "job_designer.terminated", +} as const; + +export type AnalyticsEvent = typeof ANALYTICS_EVENTS[keyof typeof ANALYTICS_EVENTS]; + +export type AnalyticsProperties = Record; + +export type TrackEventFunction = (event: string, properties?: AnalyticsProperties) => void; + +/** + * Records an event, if the host is listening. + * + * Swallows a throwing recorder deliberately: analytics is the least important + * thing on the page, and an instrumentation bug must never be what stops + * somebody submitting a job. + */ +export function trackEvent(event: AnalyticsEvent, properties?: AnalyticsProperties): void { + const { trackEvent: track } = getInjectedDeps() as { trackEvent?: TrackEventFunction }; + if (typeof track !== "function") return; + + try { + track(event, properties); + } catch { + // Deliberately silent — see above. + } +} + +/** + * Seconds between two timestamps, for the duration properties. Returns undefined + * rather than a negative or absurd number when the start is missing, so a + * dashboard is never asked to average nonsense. + */ +export function durationSince(startedAtMs?: number, nowMs?: number): number | undefined { + if (!startedAtMs) return undefined; + + const elapsed = ((nowMs ?? Date.now()) - startedAtMs) / 1000; + + return elapsed >= 0 ? Math.round(elapsed) : undefined; +} + +/** The shape of a preflight report, reduced to what is worth recording. */ +export function summarizeReportForAnalytics(report: { + rows: Array<{ id: string; state: string }>; +}): AnalyticsProperties { + const countOf = (state: string) => report.rows.filter((row) => row.state === state).length; + + return { + checks: report.rows.length, + passed: countOf("pass"), + warned: countOf("warn"), + failed: countOf("fail"), + skipped: countOf("skip"), + // Which checks, not just how many: "compute fails 40% of the time" points + // at a step, where "1.4 failures per run" points at nothing. + failedChecks: report.rows.filter((row) => row.state === "fail").map((row) => row.id), + }; +} diff --git a/src/components/ComputeTab.jsx b/src/components/ComputeTab.jsx index 90374d6..484eeaa 100644 --- a/src/components/ComputeTab.jsx +++ b/src/components/ComputeTab.jsx @@ -22,6 +22,10 @@ export default function ComputeTab(props) { currentAccount, currentUser, accountUsersIsLoading, + useComputeCards, + clusterMetadata, + computeQuota, + runs, } = props; return ( @@ -38,6 +42,10 @@ export default function ComputeTab(props) { showAdvancedOptions={showAdvancedOptions} accountUsers={accountUsers} isAccountUsersLoading={accountUsersIsLoading} + useComputeCards={useComputeCards} + clusterMetadata={clusterMetadata} + computeQuota={computeQuota} + runs={runs} />
); @@ -58,6 +66,12 @@ ComputeTab.propTypes = { currentUser: PropTypes.object.isRequired, currentAccount: PropTypes.object.isRequired, clusters: PropTypes.arrayOf(PropTypes.object).isRequired, + /* Phase 2.3 (@mat3ra/ive): cluster cards, resource steppers and the estimate panel. + Ignored by ive releases predating it. */ + useComputeCards: PropTypes.bool, + clusterMetadata: PropTypes.arrayOf(PropTypes.object), + computeQuota: PropTypes.object, + runs: PropTypes.number, }; ComputeTab.defaultProps = { diff --git a/src/components/Job.jsx b/src/components/Job.jsx index 3240696..6055421 100644 --- a/src/components/Job.jsx +++ b/src/components/Job.jsx @@ -1,15 +1,34 @@ /* eslint-disable jsx-a11y/anchor-is-valid */ import IconByName from "@mat3ra/cove/dist/mui/components/icon/IconByName"; +import { JobLifecycleTimeline } from "@mat3ra/cove/dist/mui/components/lifecycle/LifecycleTimeline"; import { showWarningAlert } from "@mat3ra/cove/dist/other/alerts"; import Alert from "@mui/material/Alert"; +import AlertTitle from "@mui/material/AlertTitle"; import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; import lodash from "lodash"; import { mix } from "mixwith"; import React from "react"; import { ErrorBoundary } from "react-error-boundary"; import { TAB_NAVIGATION_CONFIG } from "@mat3ra/jode"; +import { ANALYTICS_EVENTS, durationSince, trackEvent } from "../analytics"; +import { estimateComputeUsage, formatEstimate } from "../computeEstimate"; +import { formatBlockedReason } from "../jobSubmission"; +import { normalizeDialogHandle } from "../dialogHandles"; +import { getJobReadiness } from "../jobReadiness"; +import { getSaveState, getSaveStateLabel, shouldWarnBeforeLeaving } from "../saveState"; import { shouldPersistJobOnUpdate } from "../shouldPersistJobOnUpdate"; import ComputeTab from "./ComputeTab"; +import JobContextStrip from "./JobContextStrip"; +import JobReadinessRail from "./JobReadinessRail"; +import PreflightDialog from "./PreflightDialog"; import DatasetTab from "./DatasetTab"; import FilesTab from "./FilesTab"; import MaterialTab from "./MaterialTab"; @@ -74,6 +93,48 @@ const getConditionalTabs = (config, conditionalMap, key) => Object.values(config).filter((tab) => conditionalMap[tab[key]] !== false); const createMessageTextTAPi18n = (key) => key; +/** + * Shown when the designer throws while rendering. + * + * The fallback used to be `
`: a render error produced a silently blank + * page, with nothing to report and no way back. Anything the reader can act on + * beats an empty screen, so this names what happened and offers a reload; the + * digest is there to be pasted into a bug report. + */ +function JobDesignerErrorCard({ error, resetErrorBoundary }) { + return ( + + + Reload designer + + } + > + This job could not be displayed + Something in the designer failed to render. Your saved job is unaffected — reloading + usually clears it. If it keeps happening, include this with a report: + + {error?.message ?? String(error)} + + + + ); +} + // TODO: resolve the problem with unit output update and make job component deep-comparable again class Job extends mix(React.Component).with( StatePropsCompareOnUpdateForJobMIxin, @@ -88,6 +149,10 @@ class Job extends mix(React.Component).with( entity: this.props.job, // make a copy to avoid modifying original object `parentJob` currentTab: this.defaultTab, isWorkflowLoading: false, + isTerminateConfirmationOpen: false, + hasUnsavedChanges: false, + isPreflightOpen: false, + hasSubmitted: false, }; this.onEntityUpdate = this.props.onUpdate; this.onWorkflowUpdate = this.onWorkflowUpdate.bind(this); @@ -107,9 +172,20 @@ class Job extends mix(React.Component).with( onWorkflowUpdate(workflow) { const job = this.state.entity; job.setWorkflow(workflow); + this.markUnsavedChanges(); this.props.onUpdate(job); } + /** + * Something the reader did changed the job. Deliberately called from the + * handlers rather than from `persistJob()`: that also runs on mount and on + * entering the Workflow tab, neither of which is an edit, and claiming + * unsaved changes for them would make the indicator meaningless. + */ + markUnsavedChanges() { + if (!this.state.hasUnsavedChanges) this.setState({ hasUnsavedChanges: true }); + } + get computedEntity() { return this.state.entity; } @@ -162,18 +238,159 @@ class Job extends mix(React.Component).with( componentDidMount() { this.persistJob(); + window.addEventListener("beforeunload", this.warnIfLeavingWithUnsavedChanges); + + // Baseline for "time to first submit". Only for drafts: opening a job that + // has already run is a different act and would skew the number. + if (this.state.entity.isInInitialStatus) { + this.openedAtMs = Date.now(); + trackEvent(ANALYTICS_EVENTS.designerOpened, { + useGuidedDesigner: Boolean(this.props.useGuidedDesigner), + startedFromParent: Boolean(this.state.entity.getParentJobClient?.()), + }); + } + } + + /** + * Browsers ignore custom text here and show their own wording; setting + * returnValue is what makes the prompt appear at all. + */ + warnIfLeavingWithUnsavedChanges = (event) => { + if (!shouldWarnBeforeLeaving(this.saveStateInputs)) return undefined; + + event.preventDefault(); + event.returnValue = ""; + return ""; + }; + + /** + * Pure derivation - no entity mutation, and in particular no `job.render()`. + * Recomputed per render rather than memoised: it walks a handful of arrays, + * whereas caching it would mean tracking invalidation across the same + * in-place model mutations that already make this component hard to reason + * about. + */ + get jobReadiness() { + return getJobReadiness({ + job: this.state.entity, + materials: this.props.materials ?? [], + isUsingMaterials: this.isUsingMaterialsTab, + datasetConfig: this.props.datasetConfig, + editable: Boolean(this.props.editable), + clusterMetadata: this.getPreflightContext().clusterMetadata, + }); + } + + /** + * The "Select …" dialog that fills each step. + * + * This is what makes the rail a creation path rather than navigation: without + * it the only way to choose a material or a workflow is still the actions + * dropdown, which is the thing the rail exists to replace. Review has nothing + * to choose, so it gets no affordance. + */ + get readinessStepDialogs() { + if (!this.state.entity.isInInitialStatus) return {}; + + return { + material: this.openSelectMaterialsDialog, + dataset: this.openDatasetUploadsDialog, + workflow: this.openSelectWorkflowDialog, + }; + } + + /** The rail's Review step has no tab of its own; it lands on Compute. */ + onReadinessStepSelect = (stepId) => { + // The step a session ends on is where abandonment happens. + trackEvent(ANALYTICS_EVENTS.stepSelected, { stepId }); + this.setCurrentTab(stepId === "review" ? TAB_NAVIGATION_CONFIG.compute.id : stepId); + }; + + /** + * Core-hours the job will consume, and what they cost where the host told us + * the price. Undefined until nodes, cores and a walltime are all set — the + * chip is then left out rather than showing a zero the reader would read as + * "free". + */ + get estimateLabel() { + const { clusterMetadata } = this.getPreflightContext(); + const runs = this.isUsingMaterialsTab ? this.props.materials?.length ?? 0 : 1; + + return formatEstimate( + estimateComputeUsage(this.state.entity.compute, clusterMetadata, runs), + ); + } + + openPreflight = () => this.setState({ isPreflightOpen: true }); + + closePreflight = () => this.setState({ isPreflightOpen: false }); + + /** + * Read at the moment the checks run rather than captured at render time: the + * job entity is mutated in place, and the checks must judge what would + * actually be submitted. + */ + getPreflightContext = () => ({ + job: this.state.entity, + materials: this.props.materials ?? [], + isUsingMaterials: this.isUsingMaterialsTab, + // Pricing, limits and quota are not in the job document — the host injects + // them. Absent, the cost and limit checks report that they cannot judge + // rather than passing on no evidence. + clusterMetadata: getInjectedDeps().clusterMetadata ?? this.props.clusterMetadata, + quota: getInjectedDeps().computeQuota ?? this.props.computeQuota, + }); + + /** Every unit across the job's subworkflows, in workflow order. */ + get workflowUnits() { + const subworkflows = this.state.entity.workflow?.subworkflows ?? []; + + return subworkflows.flatMap( + (subworkflow) => subworkflow?.unitsInstances ?? subworkflow?.units ?? [], + ); + } + + confirmPreflightSubmit = () => { + // Navigation waits for the status to actually change (see + // componentDidUpdate). Switching now would land the reader on a Results + // tab that the conditional tab map has not enabled yet, because the job + // is still `pre-submission` until the server says otherwise. + this.submittedAtMs = Date.now(); + this.setState({ isPreflightOpen: false, hasSubmitted: true }); + this.props.onSubmit?.(); + }; + + get saveStateInputs() { + return { + hasUnsavedChanges: this.state.hasUnsavedChanges, + editable: Boolean(this.props.editable), + isSaving: Boolean(this.props.isLoading), + }; } componentDidUpdate(prevProps) { if (prevProps.job !== this.props.job) { this.setState({ entity: this.props.job }); } + // The job the reader just submitted has left their hands; what they want + // next is to watch it run, not the form they finished with (C2). Fires on + // the transition rather than on the click, so the monitor is reachable by + // the time we get there. + if (this.state.hasSubmitted && !this.props.job.isInInitialStatus) { + this.setState({ hasSubmitted: false }); + trackEvent(ANALYTICS_EVENTS.jobSubmitted, { + secondsInDesigner: durationSince(this.openedAtMs), + useGuidedDesigner: Boolean(this.props.useGuidedDesigner), + }); + this.setCurrentTab(TAB_NAVIGATION_CONFIG.results.id); + } if (shouldPersistJobOnUpdate(prevProps, this.props)) { this.persistJob(); } } componentWillUnmount() { + window.removeEventListener("beforeunload", this.warnIfLeavingWithUnsavedChanges); this.props.onDestroy(); } @@ -182,13 +399,20 @@ class Job extends mix(React.Component).with( this.shouldComponentUpdateForJob(nextProps, nextState) || this.shouldComponentUpdateFromComputableEntityMixin(nextProps, nextState) || this.state.currentTab !== nextState.currentTab || - this.state.isWorkflowLoading !== nextState.isWorkflowLoading + this.state.isWorkflowLoading !== nextState.isWorkflowLoading || + // Without this the confirmation never appears: the mixins below only + // consider the job entity, so a state change this component owns is + // invisible to them and the render is skipped. + this.state.isTerminateConfirmationOpen !== nextState.isTerminateConfirmationOpen || + this.state.hasUnsavedChanges !== nextState.hasUnsavedChanges || + this.state.isPreflightOpen !== nextState.isPreflightOpen ); } onComputeUpdate = (compute) => { const job = this.state.entity; job.setCompute(compute); + this.markUnsavedChanges(); this._resetStateEntityAndUpdateParents(job); }; @@ -207,23 +431,29 @@ class Job extends mix(React.Component).with( onNameUpdate = (name) => { const job = this.state.entity; job.setName(name); + this.markUnsavedChanges(); this._resetStateEntityAndUpdateParents(job); }; setParentJob = (parent) => { const job = this.state.entity; job.setParent(parent); + this.markUnsavedChanges(); this._resetStateEntityAndUpdateParents(job); }; onParentRemove = () => { const job = this.state.entity; job.unsetParent(); + this.markUnsavedChanges(); // Workaround to propagate changes to component TODO: figure out how to avoid using forceUpdate this._resetStateEntityAndUpdateParents(job); }; renderParentJob() { + // The context strip carries the parent as a chip; two of them is one too many. + if (this.props.useGuidedDesigner) return null; + const parentJob = this.state.entity.getParentJobClient?.(); return parentJob ? ( @@ -272,18 +502,10 @@ class Job extends mix(React.Component).with( content: "Select dataset", onClick: this.openDatasetUploadsDialog, }, - { - isShown: Boolean(job.id && job.isInInitialStatus), - id: "select-submit", - content: "Submit", - onClick: this.props.onSubmit, - }, - { - isShown: Boolean(job.id && job.isInRunningStatus), - id: "select-terminate", - content: "Terminate", - onClick: this.props.onTerminate, - }, + // Submit and Terminate deliberately do NOT live here any more: they are + // the two actions the whole screen exists to reach, and a menu item that + // silently disappears when the job is not ready tells the reader nothing. + // They are header buttons now - see renderSubmitAction(). ]; // renders divider if some actions should be shown @@ -294,6 +516,172 @@ class Job extends mix(React.Component).with( return actions; }; + /** + * Persists the entity, then clears the unsaved-changes flag. + * + * Both header paths (injected organism and package-native fallback) go + * through here so the indicator cannot be cleared by one and missed by the + * other - and so the flag only drops once the save has actually been handed + * off, not merely requested. + */ + saveJob(save, ...args) { + this._resetStateEntityAndUpdateParents(this.state.entity, () => { + save(...args); + this.setState({ hasUnsavedChanges: false }); + }); + } + + openTerminateConfirmation = () => this.setState({ isTerminateConfirmationOpen: true }); + + closeTerminateConfirmation = () => this.setState({ isTerminateConfirmationOpen: false }); + + confirmTerminate = () => { + // Terminating soon after submitting is the proxy for "submitted with the + // wrong settings" — the thing the estimate and preflight should reduce. + trackEvent(ANALYTICS_EVENTS.jobTerminated, { + secondsSinceSubmit: durationSince(this.submittedAtMs), + }); + this.closeTerminateConfirmation(); + this.props.onTerminate(); + }; + + /** + * Submit and Terminate as header buttons rather than dropdown items. + * + * A disabled Submit says what is missing instead of vanishing, which is what + * the dropdown did. Terminate asks first - it kills a running job, and it + * used to be a single unconfirmed click. + */ + /** + * Where the job is in its life, in the header. + * + * Replaces the status tint on the header icon (`iconCls: text-${statusCls}`), + * which had one glyph carrying "queued", "running" and "errored" alike and + * could say nothing about what had already happened or when. On a draft it + * also does the work of telling a first-time reader what is coming. + */ + renderLifecycleTimeline() { + const job = this.state.entity; + + return ( + + ); + } + + /** + * Says whether the job on screen has been persisted. Only while editable: + * a read-only view has nothing to save, so the words would be noise. + */ + renderSaveStateIndicator() { + if (!this.props.editable) return null; + + const saveState = getSaveState(this.saveStateInputs); + + return ( + + {getSaveStateLabel(saveState)} + + ); + } + + renderSubmitAction() { + const job = this.state.entity; + const { editable, onSubmit } = this.props; + + if (!editable) return null; + + if (job.isInRunningStatus) { + return ( + + ); + } + + if (!job.isInInitialStatus) return null; + + // Read from the readiness selector, not from `getSubmitBlockers` directly: + // it is the one that knows about host-published cluster limits, and a + // Submit button that stayed enabled over a preflight that refuses would be + // the designer contradicting itself. + const blockedReason = formatBlockedReason(this.jobReadiness.blockingReasons); + // Under the guided designer Submit opens the preflight, which is where the + // job is actually submitted from. Hosts still on the legacy layout keep + // today's one-click submit rather than silently gaining a second step. + const usePreflight = Boolean(this.props.useGuidedDesigner); + + return ( + + {/* span: MUI needs a non-disabled wrapper for the tooltip to fire. */} + + + + + ); + } + + renderPreflightDialog() { + if (!this.props.useGuidedDesigner) return null; + + return ( + + ); + } + + renderTerminateConfirmation() { + const job = this.state.entity; + + return ( + + Terminate this job? + + + {job.name} is still running. Terminating stops it where it is; + results produced so far are kept, but the run cannot be resumed. + + + + + + + + ); + } + getSaveBtnProps() { const isDesignerLoading = this.props.isLoading || this.state.isWorkflowLoading; return { @@ -315,9 +703,7 @@ class Job extends mix(React.Component).with( // persist the entity as it was on the very first render (e.g. the // original auto-generated job, before any parent/workflow/materials // selection or rename) — silently reverting all later edits on Save. - this._resetStateEntityAndUpdateParents(this.state.entity, () => - this.props.onSave(...args), - ); + this.saveJob((...saveArgs) => this.props.onSave(...saveArgs), ...args); }, }, ], @@ -401,8 +787,8 @@ class Job extends mix(React.Component).with( }; openAddMaterialsDialog = () => { - const [openAddMaterialsDialog, closeAddMaterialsDialog] = - this.props.jobDialogs.selectMaterialsReduxDialog; + const { open: openAddMaterialsDialog, close: closeAddMaterialsDialog } = + normalizeDialogHandle(this.props.jobDialogs.selectMaterialsReduxDialog); openAddMaterialsDialog({ id: "material-add", @@ -418,8 +804,8 @@ class Job extends mix(React.Component).with( }; openSelectMaterialsDialog = () => { - const [openSelectMaterialsDialog, closeSelectMaterialsDialog] = - this.props.jobDialogs.selectMaterialsReduxDialog; + const { open: openSelectMaterialsDialog, close: closeSelectMaterialsDialog } = + normalizeDialogHandle(this.props.jobDialogs.selectMaterialsReduxDialog); openSelectMaterialsDialog({ title: "Select Materials", @@ -434,8 +820,8 @@ class Job extends mix(React.Component).with( }; openSelectParentJobDialog = () => { - const [openSelectParentJobDialog, closeSelectParentJobDialog] = - this.props.jobDialogs.selectParentJobExplorerDialog; + const { open: openSelectParentJobDialog, close: closeSelectParentJobDialog } = + normalizeDialogHandle(this.props.jobDialogs.selectParentJobExplorerDialog); openSelectParentJobDialog({ onClose: closeSelectParentJobDialog, @@ -444,14 +830,16 @@ class Job extends mix(React.Component).with( }; closeSelectParentJobDialog() { - const [, closeSelectParentJobDialog] = this.props.jobDialogs.selectParentJobExplorerDialog; + const { close: closeSelectParentJobDialog } = normalizeDialogHandle( + this.props.jobDialogs.selectParentJobExplorerDialog, + ); closeSelectParentJobDialog(); } openSelectWorkflowDialog = () => { - const [openSelectWorkflowDialog, closeSelectWorkflowDialog] = - this.props.jobDialogs.selectWorkflowReduxDialog; + const { open: openSelectWorkflowDialog, close: closeSelectWorkflowDialog } = + normalizeDialogHandle(this.props.jobDialogs.selectWorkflowReduxDialog); openSelectWorkflowDialog({ onClose: closeSelectWorkflowDialog, @@ -460,14 +848,16 @@ class Job extends mix(React.Component).with( }; closeSelectWorkflowDialog = () => { - const [, closeSelectWorkflowDialog] = this.props.jobDialogs.selectWorkflowReduxDialog; + const { close: closeSelectWorkflowDialog } = normalizeDialogHandle( + this.props.jobDialogs.selectWorkflowReduxDialog, + ); closeSelectWorkflowDialog(); }; openDatasetUploadsDialog = () => { - const [openDatasetUploadsDialog, closeDatasetUploadsDialog] = - this.props.jobDialogs.datasetUploadsReduxDialog; + const { open: openDatasetUploadsDialog, close: closeDatasetUploadsDialog } = + normalizeDialogHandle(this.props.jobDialogs.datasetUploadsReduxDialog); openDatasetUploadsDialog({ onClose: closeDatasetUploadsDialog, @@ -582,6 +972,15 @@ class Job extends mix(React.Component).with( const activeTabIndex = tabsToRender.findIndex((item) => item.id === this.state.currentTab); + // Phase 2 layout is opt-in per host so the webapp and the demo can flip + // independently; the legacy tab strip stays until parity is verified. + const useGuidedDesigner = Boolean(this.props.useGuidedDesigner); + const readiness = this.jobReadiness; + const parentJobClient = job.getParentJobClient?.(); + const parentJobForStrip = parentJobClient + ? { name: parentJobClient.name, projectSlug: parentJobClient._project?.slug } + : null; + const isDescriptionEditable = this.isDescriptionEditable(job); const isDesignerLoading = isLoading || this.state.isWorkflowLoading; const dropdownProps = this.getDropdownProps(); @@ -594,7 +993,7 @@ class Job extends mix(React.Component).with( const InjectedEntityHeader = getInjectedDeps().EntityHeaderComponent; return ( - }> + {InjectedEntityHeader ? ( - this._resetStateEntityAndUpdateParents(this.state.entity, () => - this.props.onSave(omitRedirect), + this.saveJob( + (redirectFlag) => this.props.onSave(redirectFlag), + omitRedirect, ), }} dropdownProps={dropdownProps} @@ -625,6 +1024,9 @@ class Job extends mix(React.Component).with( isDescriptionEditable={isDescriptionEditable} onDescriptionUpdate={this.onDescriptionUpdate} > + {this.renderLifecycleTimeline()} + {this.renderSaveStateIndicator()} + {this.renderSubmitAction()} {headerChildren ?? null} ) : ( @@ -643,19 +1045,59 @@ class Job extends mix(React.Component).with( empty menu. */} {dropdownProps.isShown && } {this.props.editable && } + {this.renderLifecycleTimeline()} + {this.renderSaveStateIndicator()} + {this.renderSubmitAction()} {headerChildren ?? null} )} + {this.renderTerminateConfirmation()} + {this.renderPreflightDialog()} {this.renderParentJob()} {this.renderErrors()} {this.renderWarnings()} - - + {useGuidedDesigner ? ( + + ) : null} + {useGuidedDesigner ? null : ( + + )} + + {useGuidedDesigner ? ( + + ) : null}
{this.state.isWorkflowLoading ? ( @@ -672,6 +1114,7 @@ class Job extends mix(React.Component).with( index={index} length={length} onUpdateIndex={onUpdateIndex} + materials={materials} onMaterialRemove={onMaterialRemove} addRemoveAllowed={!job.id} openAddMaterialsDialog={this.openAddMaterialsDialog} @@ -719,6 +1162,10 @@ class Job extends mix(React.Component).with( createMetaProperty={createMetaProperty} jobProperties={jobProperties} isDescriptionEditable={isDescriptionEditable} + // Phase 3.3 lives in @mat3ra/workflow-designer; + // inert until a release carrying it is installed. + useUnitInspector={useGuidedDesigner} + useHostTheme={useGuidedDesigner} /> )} {isCurrentTabCompute && ( @@ -736,6 +1183,16 @@ class Job extends mix(React.Component).with( accountUsersIsLoading={accountUsersIsLoading} currentUser={currentUser} currentAccount={currentAccount} + // Phase 2.3 lives in @mat3ra/ive; these are inert + // until a release carrying it is installed. + useComputeCards={useGuidedDesigner} + clusterMetadata={this.getPreflightContext().clusterMetadata} + computeQuota={this.getPreflightContext().quota} + runs={ + this.isUsingMaterialsTab + ? Math.max(materials?.length ?? 1, 1) + : 1 + } /> )} {isCurrentTabResults && ( @@ -753,6 +1210,12 @@ class Job extends mix(React.Component).with( MaterialComponent={MaterialViewerComponent} fileUtils={getFileUtils()} DataGridComponent={getInjectedDeps().DataGridComponent} + // Phase 3.2 lives in @mat3ra/jove; inert until a + // release carrying it is installed. + showRunMonitor={useGuidedDesigner && !job.isInInitialStatus} + units={this.workflowUnits} + logText={getInjectedDeps().getJobLogTail?.(job)} + hasLogSource={Boolean(getInjectedDeps().getJobLogTail)} /> )} {isCurrentTabFiles && ( diff --git a/src/components/JobContextStrip.tsx b/src/components/JobContextStrip.tsx new file mode 100644 index 0000000..b347633 --- /dev/null +++ b/src/components/JobContextStrip.tsx @@ -0,0 +1,134 @@ +import Chip from "@mui/material/Chip"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import React from "react"; + +import type { ReadinessStep } from "../jobReadiness"; + +export interface JobContextStripProps { + steps: ReadinessStep[]; + onSelect: (stepId: string) => void; + /** Parent job, when this job derives from one. Removable while editable. */ + parentJob?: { name?: string; projectSlug?: string } | null; + onParentRemove?: () => void; + /** + * Pre-formatted core-hours and cost from `estimateComputeUsage`. Absent when + * the compute configuration is incomplete or the host published no pricing — + * the chip is then left out rather than shown empty or as zero. + */ + estimateLabel?: string; +} + +/** Steps whose selections are worth carrying on every screen. */ +const CONTEXT_STEP_IDS = new Set(["material", "dataset", "workflow", "compute"]); + +/** + * The job's selections, visible from every step. + * + * Checking which material a job will run on used to mean leaving the step you + * were on — the Compute tab showed no trace of the material or workflow, and + * vice versa. Each chip is also the way back to the step that owns it. + * + * The parent job lives here too. It used to be a dismissable `Alert` sitting + * above the tabs, which is a lot of screen for one fact and put a destructive + * "unset parent" behind an X that reads as "hide this message". + */ +export default function JobContextStrip({ + steps, + onSelect, + parentJob, + onParentRemove, + estimateLabel, +}: JobContextStripProps) { + const contextSteps = steps.filter((step) => CONTEXT_STEP_IDS.has(step.id)); + if (!contextSteps.length && !parentJob) return null; + + return ( + + {contextSteps.map((step) => { + const needsAttention = step.state !== "complete"; + + return ( + onSelect(step.id)} + label={ + + + {step.label} + + + {step.summary} + + + } + /> + ); + })} + + {estimateLabel ? ( + onSelect("compute")} + label={ + + + Estimate + + + {estimateLabel} + + + } + /> + ) : null} + + {parentJob ? ( + + + Parent + + + {parentJob.name} + {parentJob.projectSlug ? ` · ${parentJob.projectSlug}` : ""} + + + } + /> + ) : null} + + ); +} diff --git a/src/components/JobReadinessRail.tsx b/src/components/JobReadinessRail.tsx new file mode 100644 index 0000000..e91aefe --- /dev/null +++ b/src/components/JobReadinessRail.tsx @@ -0,0 +1,187 @@ +import IconByName from "@mat3ra/cove/dist/mui/components/icon/IconByName"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import ButtonBase from "@mui/material/ButtonBase"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import React from "react"; + +import type { ReadinessState, ReadinessStep } from "../jobReadiness"; +import { getMessage } from "../messages"; + +/** Icon and tone per step state. Never color alone — every state has a glyph. */ +const STATE_PRESENTATION: Record< + ReadinessState, + { iconName: string; color: string; isMuted?: boolean } +> = { + complete: { iconName: "shapes.check", color: "success.main" }, + attention: { iconName: "shapes.info", color: "warning.main" }, + empty: { iconName: "shapes.circle", color: "text.disabled", isMuted: true }, + unavailable: { iconName: "shapes.lock", color: "text.disabled", isMuted: true }, +}; + +export interface JobReadinessRailProps { + steps: ReadinessStep[]; + activeStepId: string; + onSelect: (stepId: string) => void; + /** + * Opens the "Select …" dialog that fills a step, keyed by step id. A step + * with no entry gets no Change affordance — Review has nothing to choose. + * + * This is what makes the rail a creation path rather than just navigation: + * without it the only way to pick a material or a workflow is still the + * actions dropdown, which is the thing the rail exists to replace. + */ + onChange?: Record void) | undefined>; + /** False for shared or finished jobs: the rail renders without Change. */ + editable?: boolean; + /** Rendered under the steps — parent job, import, and other power actions. */ + children?: React.ReactNode; +} + +/** + * The job's lifecycle, with its current state on show. + * + * Replaces the numbered tab strip. Those tabs implied a sequence — "1. Materials, + * 2. Workflow, 3. Compute" — while looking identical whether a step was done or + * untouched, and the actions that complete them lived in a dropdown. Each row + * here carries its own state and a line saying what is chosen. + * + * Keyboard: the steps are a toolbar of buttons, arrow keys move between them, + * and the active one carries `aria-current`. + */ +export default function JobReadinessRail({ + steps, + activeStepId, + onSelect, + onChange = {}, + editable = true, + children, +}: JobReadinessRailProps) { + const stepRefs = React.useRef>([]); + + const handleKeyDown = (event: React.KeyboardEvent, index: number) => { + const offset = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[event.key]; + if (!offset) return; + + event.preventDefault(); + const nextIndex = (index + offset + steps.length) % steps.length; + stepRefs.current[nextIndex]?.focus(); + }; + + return ( + + {steps.map((step, index) => { + const presentation = STATE_PRESENTATION[step.state]; + const isActive = step.id === activeStepId; + const change = editable ? onChange[step.id] : undefined; + + return ( + // A sibling, not a child: the step row is a button, and a + // button inside a button is invalid and unreachable by + // keyboard. + + { + stepRefs.current[index] = node as HTMLButtonElement | null; + }} + id={`job-step-${step.id}`} + aria-current={isActive ? "step" : undefined} + disabled={step.state === "unavailable"} + onClick={() => onSelect(step.id)} + onKeyDown={(event) => handleKeyDown(event, index)} + sx={{ + flexGrow: 1, + justifyContent: "flex-start", + gap: 1.25, + px: 1.5, + py: 1.25, + borderRadius: 1, + textAlign: "left", + minWidth: 0, + "&.Mui-focusVisible": { + outline: "2px solid", + outlineColor: "primary.main", + }, + }} + > + + + + {step.label} + + + {step.summary} + + + + + {change ? ( + + ) : null} + + ); + })} + + {children ? ( + + {children} + + ) : null} + + ); +} diff --git a/src/components/MaterialMetadataPanel.tsx b/src/components/MaterialMetadataPanel.tsx new file mode 100644 index 0000000..6b682a2 --- /dev/null +++ b/src/components/MaterialMetadataPanel.tsx @@ -0,0 +1,85 @@ +import Box from "@mui/material/Box"; +import Link from "@mui/material/Link"; +import Paper from "@mui/material/Paper"; +import Typography from "@mui/material/Typography"; +import React from "react"; + +import { getMaterialSummary } from "../materialSummary"; + +/** One fact. Rendered only when there is one — an empty row says nothing. */ +function MetadataRow({ label, children }: { label: string; children?: React.ReactNode }) { + if (children === undefined || children === null || children === "") return null; + + return ( + + + {label} + + (theme as any).fonts?.monospace, + fontVariantNumeric: "tabular-nums", + textAlign: "right", + wordBreak: "break-word", + }} + > + {children} + + + ); +} + +/** + * What the structure on screen actually is, beside the viewer. + * + * The Materials tab renders a 3D canvas and nothing else, so formula, lattice, + * atom count and provenance — the things worth checking before spending + * core-hours on a run — could only be found by leaving the designer. + */ +/** The source id, linked out when the model knows where it came from. */ +function SourceValue({ source }: { source?: { id: string; url?: string } }) { + if (!source) return null; + if (!source.url) return {source.id}; + + return ( + + {source.id} + + ); +} + +export default function MaterialMetadataPanel({ material }: { material: any }) { + const summary = getMaterialSummary(material); + + // Nothing legible to report: better to show no panel than an empty frame. + const hasAnything = Boolean( + summary.formula || summary.latticeType || summary.atomCount || summary.source, + ); + if (!hasAnything) return null; + + return ( + + {summary.formula} + {summary.latticeType} + {summary.latticeParameters} + {summary.latticeAngles} + {summary.spaceGroup} + {summary.atomCount} + + {summary.source ? : undefined} + + + ); +} diff --git a/src/components/MaterialTab.tsx b/src/components/MaterialTab.tsx index 3210a44..e4f85bb 100644 --- a/src/components/MaterialTab.tsx +++ b/src/components/MaterialTab.tsx @@ -1,6 +1,10 @@ +import Box from "@mui/material/Box"; import setClass from "classnames"; import React from "react"; +import MaterialMetadataPanel from "./MaterialMetadataPanel"; +import MaterialsTray from "./MaterialsTray"; + // In standalone mode, @mat3ra/made exports the data class (not a React component). // Render a simple read-only display of the material name as a fallback. function MaterialNameFallback({ material }: { material: any; [key: string]: any }) { @@ -39,9 +43,16 @@ interface MaterialTabProps { publicAccount: any; profile: any; addRemoveAllowed: boolean | (() => void); - onUpdateIndex: () => void; + /** Switches the material in the viewer; dispatches `switchMaterialByIndex`. */ + onUpdateIndex: (index: number) => void; onMaterialRemove: () => void; openAddMaterialsDialog: () => void; + /** + * Every material attached to the job, for the tray above the viewer. Optional + * so hosts that only pass the active material keep working — the tray is then + * simply not rendered. + */ + materials?: any[]; /** * Optional injectable material viewer component (e.g. ThreeDEditor from wave.js or a mave component). * When provided it receives the full {@link MaterialViewerComponentProps} so that it can render @@ -64,38 +75,67 @@ function MaterialTab({ onUpdateIndex, onMaterialRemove, openAddMaterialsDialog, + materials, MaterialViewerComponent, }: MaterialTabProps) { + // Hosts that pass only the active material still get a one-chip tray. + let trayMaterials: any[] = []; + if (materials?.length) trayMaterials = materials; + else if (material) trayMaterials = [material]; + return (
- {MaterialViewerComponent ? ( - - ) : ( - - )} + + {/* Viewer and metadata side by side: the structure is the subject, the + facts about it are what a reader checks before spending core-hours. */} + + + {MaterialViewerComponent ? ( + void} + onRemove={onMaterialRemove} + onAdd={openAddMaterialsDialog} + /> + ) : ( + + )} + + +
); } diff --git a/src/components/MaterialsTray.tsx b/src/components/MaterialsTray.tsx new file mode 100644 index 0000000..af350fe --- /dev/null +++ b/src/components/MaterialsTray.tsx @@ -0,0 +1,93 @@ +import IconByName from "@mat3ra/cove/dist/mui/components/icon/IconByName"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Chip from "@mui/material/Chip"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import React from "react"; + +import { getBatchDescription, getMaterialSummary } from "../materialSummary"; + +export interface MaterialsTrayProps { + materials: any[]; + /** Index of the material currently in the viewer. */ + activeIndex: number; + onSelect: (index: number) => void; + onRemove?: () => void; + onAdd?: () => void; + /** False once the job is saved — its material set is fixed by then. */ + editable?: boolean; +} + +/** + * The job's materials, above the viewer. + * + * Two things were previously unavailable here: switching between materials in a + * multi-material job lived inside the *Workflow* tab, nowhere near the material + * being looked at; and a materials set silently turns one job into N, which the + * designer never said out loud. + */ +export default function MaterialsTray({ + materials, + activeIndex, + onSelect, + onRemove, + onAdd, + editable = true, +}: MaterialsTrayProps) { + if (!materials?.length) return null; + + const isBatch = materials.length > 1; + + return ( + + {materials.map((material, index) => { + const { formula, name } = getMaterialSummary(material); + const isActive = index === activeIndex; + + return ( + onSelect(index)} + // Only the material on screen can be removed: the callback the + // host gives us acts on the active one. + onDelete={editable && isActive && onRemove ? onRemove : undefined} + /> + ); + })} + + {editable && onAdd ? ( + + ) : null} + + + + + {getBatchDescription(materials.length)} + + + ); +} diff --git a/src/components/PreflightDialog.tsx b/src/components/PreflightDialog.tsx new file mode 100644 index 0000000..82c690c --- /dev/null +++ b/src/components/PreflightDialog.tsx @@ -0,0 +1,275 @@ +import IconByName from "@mat3ra/cove/dist/mui/components/icon/IconByName"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Collapse from "@mui/material/Collapse"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import LinearProgress from "@mui/material/LinearProgress"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import React from "react"; + +import { + canSubmitFromReport, + getReportSummary, + type PreflightContext, + type PreflightReport, + type PreflightRow, + type PreflightState, + runPreflightChecks, +} from "../preflight"; +import { ANALYTICS_EVENTS, summarizeReportForAnalytics, trackEvent } from "../analytics"; +import { getMessage } from "../messages"; + +/** + * Icon and tone per outcome. Never colour alone — every state has a glyph, and + * every glyph is a name cove's `IconByName` actually knows: an unmapped name + * silently falls back to a plain Circle, which would make a failure look like + * one more neutral row. + */ +const STATE_PRESENTATION: Record = { + pass: { iconName: "shapes.check", color: "success.main" }, + warn: { iconName: "shapes.info", color: "warning.main" }, + fail: { iconName: "actions.cancel", color: "error.main" }, + skip: { iconName: "shapes.circle", color: "text.disabled" }, +}; + +export interface PreflightDialogProps { + open: boolean; + onClose: () => void; + /** + * What to check, read at the moment the checks run. A getter rather than the + * context itself: the job is mutated in place, so a value captured at render + * time would have the checks judging a stale copy, and a fresh object each + * render would restart them — losing the reader's acknowledgements — every + * time anything else on the page changed. + */ + getContext: () => PreflightContext; + /** Called once the report allows it — the designer's existing submit path. */ + onSubmit: () => void; + /** Sends the reader to the step that fixes a row, closing the dialog. */ + onGoToStep: (stepId: string) => void; +} + +/** One check's outcome, with whatever the reader can do about it. */ +function PreflightRowView({ + row, + isAcknowledged, + isExpanded, + onToggleExpanded, + onAcknowledge, + onFix, +}: { + row: PreflightRow; + isAcknowledged: boolean; + isExpanded: boolean; + onToggleExpanded: () => void; + onAcknowledge: () => void; + onFix: (stepId: string) => void; +}) { + const presentation = STATE_PRESENTATION[row.state]; + const needsAcknowledgement = row.state === "warn" && !isAcknowledged; + + return ( + + + + + + {row.label} + + + {row.detail} + + + + + {row.explanation ? ( + + ) : null} + {needsAcknowledgement ? ( + + ) : null} + {isAcknowledged ? ( + + {getMessage("preflight.acknowledged")} + + ) : null} + {row.fix ? ( + + ) : null} + + + + {row.explanation ? ( + + + {row.explanation} + + + ) : null} + + ); +} + +/** + * The last look before a job is submitted. + * + * Today Submit fires immediately, and anything the job got wrong — a walltime + * over the queue cap, a template that will not render, a batch that quietly + * costs eight times what the reader expected — surfaces minutes later as a + * failed run. The checks that can be made cheaply are made here instead, while + * the reader is still in a position to change something. + * + * Three outcomes, and they mean different things: a `fail` blocks and offers the + * step that fixes it; a `warn` is the reader's call and needs acknowledging; a + * `skip` is this designer admitting it has no data to judge by, which is not the + * same as approval. + */ +export default function PreflightDialog({ + open, + onClose, + getContext, + onSubmit, + onGoToStep, +}: PreflightDialogProps) { + const [report, setReport] = React.useState(null); + const [isRunning, setIsRunning] = React.useState(false); + const [acknowledged, setAcknowledged] = React.useState([]); + const [expandedRowId, setExpandedRowId] = React.useState(null); + + const getContextRef = React.useRef(getContext); + getContextRef.current = getContext; + + const run = React.useCallback(async () => { + setIsRunning(true); + try { + const nextReport = await runPreflightChecks(getContextRef.current()); + setReport(nextReport); + // Which checks fail, not just how many — a check that fails often names + // a step whose affordances still do not work. + trackEvent( + ANALYTICS_EVENTS.preflightCompleted, + summarizeReportForAnalytics(nextReport), + ); + // Acknowledgements answer a specific report. Keep the ones whose + // warning is still there — re-running after fixing something else + // should not make the reader dismiss the same caveat again — and drop + // the rest. + setAcknowledged((previous) => + previous.filter((id) => nextReport.warnings.includes(id)), + ); + } finally { + setIsRunning(false); + } + }, []); + + React.useEffect(() => { + if (!open) return; + + setAcknowledged([]); + setExpandedRowId(null); + setReport(null); + run(); + }, [open, run]); + + const canSubmit = canSubmitFromReport(report, acknowledged); + + const handleFix = (stepId: string) => { + // Low usage against a high fail rate means the deep link is not being found. + trackEvent(ANALYTICS_EVENTS.preflightFixFollowed, { stepId }); + onClose(); + onGoToStep(stepId); + }; + + return ( + + + + {getMessage("preflight.title")} + + + {isRunning + ? getMessage("preflight.running") + : getReportSummary(report, acknowledged)} + + + + {isRunning ? : null} + + + }> + {(report?.rows ?? []).map((row) => ( + + setExpandedRowId(expandedRowId === row.id ? null : row.id) + } + onAcknowledge={() => { + trackEvent(ANALYTICS_EVENTS.preflightWarningAcknowledged, { + checkId: row.id, + }); + setAcknowledged([...acknowledged, row.id]); + }} + onFix={handleFix} + /> + ))} + {!report && !isRunning ? ( + + {getMessage("preflight.noChecks")} + + ) : null} + + + + + + + + + + ); +} diff --git a/src/components/WorkflowTab.tsx b/src/components/WorkflowTab.tsx index 0b8016c..e8ccae5 100644 --- a/src/components/WorkflowTab.tsx +++ b/src/components/WorkflowTab.tsx @@ -35,6 +35,13 @@ export type WorkflowTabProps = Pick< accountUsers: any[]; accountUsersIsLoading: boolean; isDescriptionEditable: boolean; + /** + * Phase 3.3 (@mat3ra/workflow-designer): clicking a unit opens its settings + * beside the flowchart, and the designer inherits this shell's theme instead + * of forcing a light one. Ignored by releases predating them. + */ + useUnitInspector?: boolean; + useHostTheme?: boolean; }; export default function WorkflowTab({ @@ -65,6 +72,8 @@ export default function WorkflowTab({ jobHasParent = false, isDescriptionEditable, workflowRenderGeneration, + useUnitInspector, + useHostTheme, }: WorkflowTabProps) { const onSubworkflowUnitUpdate = useCallback( (subworkflowOrSchema: SubworkflowDesignerUpdate) => { @@ -131,6 +140,11 @@ export default function WorkflowTab({ workflowRenderGeneration={workflowRenderGeneration} isDescriptionEditable={isDescriptionEditable} jobProperties={jobProperties} + // The job has its own Compute tab; without this the same screen + // offers two of them and the reader has to guess which one runs. + hideComputeSubTab + useUnitInspector={useUnitInspector} + useHostTheme={useHostTheme} />
); diff --git a/src/computeEstimate.ts b/src/computeEstimate.ts new file mode 100644 index 0000000..2ea2f0b --- /dev/null +++ b/src/computeEstimate.ts @@ -0,0 +1,172 @@ +/** + * What a compute configuration will consume, and what that costs. + * + * The designer asks for nodes, cores and a walltime and says nothing about what + * they add up to. The same arithmetic is needed in three places — the context + * strip's estimate chip, the submit preflight's budget check, and the compute + * estimate panel — so it lives here once. Three surfaces disagreeing about how + * much a job costs would be worse than none of them saying anything. + * + * TODO(SOF-8023): this is a copy. The canonical implementation is + * `@mat3ra/ive`'s `utils/computeEstimate`, beside the form that holds the live + * values; it is duplicated here only because the ive release carrying it has not + * shipped yet. Delete this module and import from ive once it has — the two are + * identical today and must not be allowed to drift. + * + * Pricing, limits and quota are not in the job document: they are properties of + * the cluster and the account, injected by the host through `setDependencies()` + * (see {@link ClusterMetadata}). Everything here degrades when that metadata is + * absent — core-hours are always computable from the job alone, cost is not, and + * an absent cost is reported as absent rather than as zero. + */ + +/** Per-cluster enrichment the host may inject. Every field is optional. */ +export interface ClusterMetadata { + /** Matches `compute.cluster.fqdn`. */ + fqdn?: string; + /** Human name, when the host has a nicer one than the FQDN. */ + name?: string; + pricePerCoreHour?: number; + /** ISO 4217, e.g. "USD". Only used for display. */ + currency?: string; + limits?: ClusterLimits; + /** Typical wait before the job starts, in minutes. */ + queueWaitMinutes?: number; +} + +export interface ClusterLimits { + maxNodes?: number; + maxPpn?: number; + /** Queue walltime cap, in hours. */ + maxWalltimeHours?: number; +} + +/** Remaining allowance for the account paying for this job. */ +export interface ComputeQuota { + remainingCoreHours?: number; + totalCoreHours?: number; + remainingBalance?: number; + currency?: string; +} + +export interface ComputeEstimate { + /** nodes × cores-per-node × walltime hours. Undefined when a term is missing. */ + coreHours?: number; + /** Only when the cluster carries a price. */ + cost?: number; + currency?: string; + walltimeHours?: number; + nodes?: number; + ppn?: number; +} + +/** The compute half of a job, as the designer holds it. */ +export interface ComputeConfiguration { + cluster?: { fqdn?: string } | null; + nodes?: number; + ppn?: number; + timeLimit?: string; + queue?: string; +} + +/** + * Walltime as hours. Accepts the `HH:MM:SS` and `D-HH:MM:SS` forms the compute + * form produces, and a bare number of hours. Returns undefined for anything it + * cannot read, so callers can tell "no walltime" from "zero hours". + */ +export function parseWalltimeHours(timeLimit?: string | number): number | undefined { + if (typeof timeLimit === "number") return Number.isFinite(timeLimit) ? timeLimit : undefined; + if (!timeLimit) return undefined; + + const [dayPart, clockPart] = timeLimit.includes("-") + ? timeLimit.split("-") + : [undefined, timeLimit]; + + const segments = clockPart.split(":").map((segment) => Number(segment)); + if (!segments.length || segments.some((segment) => !Number.isFinite(segment))) return undefined; + + const [hours = 0, minutes = 0, seconds = 0] = segments; + const days = dayPart === undefined ? 0 : Number(dayPart); + if (!Number.isFinite(days)) return undefined; + + return days * 24 + hours + minutes / 60 + seconds / 3600; +} + +/** Cluster metadata for the cluster this compute points at, if the host gave any. */ +export function findClusterMetadata( + compute?: ComputeConfiguration | null, + clusterMetadata: ClusterMetadata[] = [], +): ClusterMetadata | undefined { + const fqdn = compute?.cluster?.fqdn; + if (!fqdn) return undefined; + + return clusterMetadata.find((cluster) => cluster.fqdn === fqdn); +} + +/** + * Core-hours and cost for one run. Multi-material jobs run once per material — + * pass `runs` so the estimate is what the account will actually be charged + * rather than the per-material figure. + */ +export function estimateComputeUsage( + compute?: ComputeConfiguration | null, + clusterMetadata: ClusterMetadata[] = [], + runs = 1, +): ComputeEstimate { + const walltimeHours = parseWalltimeHours(compute?.timeLimit); + const { nodes, ppn } = compute ?? {}; + + const estimate: ComputeEstimate = { walltimeHours, nodes, ppn }; + + if (!nodes || !ppn || walltimeHours === undefined) return estimate; + + estimate.coreHours = nodes * ppn * walltimeHours * Math.max(runs, 1); + + const cluster = findClusterMetadata(compute, clusterMetadata); + if (cluster?.pricePerCoreHour !== undefined) { + estimate.cost = estimate.coreHours * cluster.pricePerCoreHour; + estimate.currency = cluster.currency; + } + + return estimate; +} + +function formatNumber(value: number): string { + // Core-hours below 10 are the interesting ones to see a decimal on; above + // that the fraction is noise next to a queue that rounds to the minute. + return value >= 10 ? String(Math.round(value)) : String(Math.round(value * 10) / 10); +} + +export function formatCoreHours(coreHours?: number): string | undefined { + if (coreHours === undefined) return undefined; + + return `${formatNumber(coreHours)} core·h`; +} + +export function formatCost(cost?: number, currency?: string): string | undefined { + if (cost === undefined) return undefined; + + const amount = cost >= 10 ? cost.toFixed(0) : cost.toFixed(2); + if (!currency) return amount; + + try { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency, + maximumFractionDigits: cost >= 10 ? 0 : 2, + }).format(cost); + } catch { + // An unknown currency code must not take the estimate down with it. + return `${amount} ${currency}`; + } +} + +/** One line for a chip or a summary row. Undefined when nothing is known yet. */ +export function formatEstimate(estimate: ComputeEstimate): string | undefined { + const coreHours = formatCoreHours(estimate.coreHours); + if (!coreHours) return undefined; + + const cost = formatCost(estimate.cost, estimate.currency); + + return cost ? `${coreHours} ≈ ${cost}` : coreHours; +} diff --git a/src/containers/JobLocalReduxContainer.tsx b/src/containers/JobLocalReduxContainer.tsx index a449ccb..7da5fa1 100644 --- a/src/containers/JobLocalReduxContainer.tsx +++ b/src/containers/JobLocalReduxContainer.tsx @@ -9,6 +9,7 @@ import { applyMiddleware, createStore } from "redux"; import logger from "redux-logger"; import { setMaterials, syncJobWorkflow, updateJob } from "../actions"; +import type { ClusterMetadata, ComputeQuota } from "../computeEstimate"; import { useJobDesignerDeps } from "../JobDesignerContext"; import { createJobDesignerReducer } from "../reducers"; import JobContainer from "./JobContainer"; @@ -104,6 +105,21 @@ interface JobStoreLocalReduxContainerProps { headerChildren?: React.ReactNode; /** Whether the job is editable. */ editable?: boolean; + /** + * Opt into the guided layout (readiness rail + context strip) instead of the + * numbered tab strip. Off by default so hosts flip it when they are ready; + * the legacy path stays until parity is verified. + */ + useGuidedDesigner?: boolean; + /** + * Per-cluster pricing, limits and queue waits. Not part of the job document — + * the host supplies it here or through `setDependencies({ clusterMetadata })`. + * Without it the estimate and the preflight's limit check report that they + * cannot judge, rather than passing on no evidence. + */ + clusterMetadata?: ClusterMetadata[]; + /** Remaining allowance for the account paying for the job, when the host tracks one. */ + computeQuota?: ComputeQuota | null; } type JobStoreLocalReduxContainerInnerProps = JobStoreLocalReduxContainerProps & { @@ -147,6 +163,9 @@ function JobStoreLocalReduxContainer({ MaterialViewerComponent, headerChildren, editable, + useGuidedDesigner, + clusterMetadata, + computeQuota, }: JobStoreLocalReduxContainerInnerProps) { const dispatch = useJobDesignerDispatch(); const stateMaterials = useJobDesignerSelector((state: State) => state.materials); @@ -308,6 +327,9 @@ function JobStoreLocalReduxContainer({ MaterialViewerComponent={MaterialViewerComponent} headerChildren={headerChildren} editable={editable} + useGuidedDesigner={useGuidedDesigner} + clusterMetadata={clusterMetadata} + computeQuota={computeQuota} /> ); } diff --git a/src/dialogHandles.ts b/src/dialogHandles.ts new file mode 100644 index 0000000..efb6776 --- /dev/null +++ b/src/dialogHandles.ts @@ -0,0 +1,52 @@ +/** + * Opening a host-provided dialog, whichever shape the host gave it in. + * + * This package's own types disagree with its own code. `JobDesignerDialogState` + * describes `{ isOpen, open, close }`, which is what the container declares and + * what the standalone demo passes; `Job.jsx` destructures `[open, close]`, which + * is what `useReduxDialog` returns and therefore what the webapp actually flows + * through. Both shapes are real and present. + * + * Nothing caught it because the only route to these openers was the actions + * dropdown, which no test or demo run ever clicked. The rail's "Change" + * affordances put them one click from the front page, and the mismatch surfaced + * immediately as `object is not iterable`. + * + * Normalising is the honest fix rather than picking a winner: a host on either + * shape works, and neither has to be migrated before the rail ships. + */ + +export type DialogTupleHandle = [(...args: unknown[]) => void, () => void]; + +export interface DialogObjectHandle { + isOpen?: boolean; + open: (...args: unknown[]) => void; + close: () => void; +} + +export type DialogHandle = DialogTupleHandle | DialogObjectHandle | undefined | null; + +export interface NormalizedDialog { + open: (...args: unknown[]) => void; + close: () => void; +} + +const noop = () => undefined; + +/** + * Always returns something callable. A missing dialog is a host that did not + * wire one up — the affordance should do nothing rather than throw on click. + */ +export function normalizeDialogHandle(handle: DialogHandle): NormalizedDialog { + if (Array.isArray(handle)) { + const [open, close] = handle; + + return { open: open ?? noop, close: close ?? noop }; + } + + if (handle && typeof handle.open === "function") { + return { open: handle.open, close: handle.close ?? noop }; + } + + return { open: noop, close: noop }; +} diff --git a/src/jobReadiness.ts b/src/jobReadiness.ts new file mode 100644 index 0000000..9583661 --- /dev/null +++ b/src/jobReadiness.ts @@ -0,0 +1,322 @@ +import { type ClusterMetadata, findClusterMetadata, parseWalltimeHours } from "./computeEstimate"; +import { getSubmitBlockers, type SubmittableJob } from "./jobSubmission"; +import { getMessage } from "./messages"; + +/** + * What the job still needs, as a sequence of steps. + * + * The designer's tabs are numbered ("1. Materials", "2. Workflow", "3. Compute") + * but carry no progress: they look identical whether a step is done, half-done + * or untouched, and the actions that complete them live in a dropdown. This is + * the single source of truth for that state — the rail, the context strip, the + * Submit button and the submit preflight all read it, so they cannot disagree + * about whether a job is ready. + * + * A pure function of the job and its inputs. It never touches the entity, and + * in particular never triggers `job.render()`: `Job.jsx` runs that from + * `persistJob()`, and recomputing readiness on a compute keystroke must not drag + * a workflow re-render along with it. + */ + +export type ReadinessState = + /** Done — carries a summary of what was chosen. */ + | "complete" + /** Started but not usable yet, or holding something invalid. */ + | "attention" + /** Nothing chosen yet. */ + | "empty" + /** Not applicable, or not reachable in this job's status. */ + | "unavailable"; + +export interface ReadinessStep { + /** Matches `TAB_NAVIGATION_CONFIG` ids where one exists, so deep links keep working. */ + id: string; + label: string; + state: ReadinessState; + /** One line naming what is chosen, or what to do. Shown under the label. */ + summary: string; +} + +export interface JobReadiness { + steps: ReadinessStep[]; + isSubmittable: boolean; + blockingReasons: string[]; + /** True once the job has left draft — creation steps become read-only summaries. */ + isRunOrFinished: boolean; +} + +export interface JobReadinessOptions { + job: SubmittableJob & { + name?: string; + status?: string; + isInFinalStatus?: boolean; + getParentJobClient?: () => { name?: string } | null; + compute?: any; + workflow?: any; + }; + materials?: any[]; + /** False for dataset-driven jobs, where a dataset takes the place of materials. */ + isUsingMaterials?: boolean; + datasetConfig?: { name?: string } | null; + /** False for shared or finished jobs: the rail renders view-only. */ + editable?: boolean; + /** + * Per-cluster limits, injected by the host. Absent, the compute step judges + * only whether a cluster was chosen — it does not invent limits to enforce. + */ + clusterMetadata?: ClusterMetadata[]; +} + +const REVIEW_STEP_ID = "review"; + +function describeMaterials(materials: any[], parentJobName?: string): string { + if (parentJobName) return getMessage("readiness.material.fromParent", { name: parentJobName }); + if (materials.length === 0) return getMessage("readiness.material.empty"); + if (materials.length === 1) { + const [material] = materials; + return material?.formula ?? material?.name ?? getMessage("readiness.material.single"); + } + + return getMessage("readiness.material.batch", { count: materials.length }); +} + +function describeWorkflow(workflow: any): string { + const subworkflows = workflow?.subworkflows ?? []; + if (!subworkflows.length) return getMessage("readiness.workflow.empty"); + + const unitCount = subworkflows.reduce( + (total: number, subworkflow: any) => total + (subworkflow?.units?.length ?? 0), + 0, + ); + const name = + workflow?.name ?? + getMessage("readiness.workflow.subworkflowCount", { count: subworkflows.length }); + + return unitCount + ? getMessage("readiness.workflow.withUnits", { name, count: unitCount }) + : name; +} + +function describeCompute(compute: any): string { + const clusterName = compute?.cluster?.fqdn; + if (!clusterName) return getMessage("readiness.compute.empty"); + + const resources = [compute?.nodes, compute?.ppn].every((value) => value) + ? `${compute.nodes}×${compute.ppn}` + : undefined; + + return [clusterName, resources, compute?.timeLimit].filter(Boolean).join(" · "); +} + +/** + * Which published limits this configuration breaks. Empty when it breaks none, + * or when the host published none to check against. + * + * The rail has to know this, not just the preflight: a green Compute step over a + * preflight that refuses to submit is the designer contradicting itself, and the + * reader would only find out at the last click. + */ +function getComputeLimitViolations(compute: any, clusterMetadata: ClusterMetadata[]): string[] { + const limits = findClusterMetadata(compute, clusterMetadata)?.limits; + if (!limits) return []; + + const walltimeHours = parseWalltimeHours(compute?.timeLimit); + const violations: string[] = []; + + if (limits.maxNodes !== undefined && (compute?.nodes ?? 0) > limits.maxNodes) { + violations.push(getMessage("readiness.compute.overNodes", { limit: limits.maxNodes })); + } + if (limits.maxPpn !== undefined && (compute?.ppn ?? 0) > limits.maxPpn) { + violations.push(getMessage("readiness.compute.overPpn", { limit: limits.maxPpn })); + } + if ( + limits.maxWalltimeHours !== undefined && + walltimeHours !== undefined && + walltimeHours > limits.maxWalltimeHours + ) { + violations.push( + getMessage("readiness.compute.overWalltime", { limit: limits.maxWalltimeHours }), + ); + } + + return violations; +} + +/** + * Steps for creating the job. After submission these stay in the rail but stop + * being things to do — they become the record of what was run. + */ +function getCreationSteps({ + job, + materials, + isUsingMaterials, + datasetConfig, + parentJobName, + clusterMetadata, +}: { + job: JobReadinessOptions["job"]; + materials: any[]; + isUsingMaterials: boolean; + datasetConfig?: { name?: string } | null; + parentJobName?: string; + clusterMetadata: ClusterMetadata[]; +}): ReadinessStep[] { + const steps: ReadinessStep[] = []; + + if (isUsingMaterials) { + const hasMaterial = materials.length > 0 || Boolean(parentJobName); + steps.push({ + id: "material", + label: getMessage("readiness.material.label"), + state: hasMaterial ? "complete" : "empty", + summary: describeMaterials(materials, parentJobName), + }); + } else { + steps.push({ + id: "dataset", + label: getMessage("readiness.dataset.label"), + state: datasetConfig ? "complete" : "empty", + summary: datasetConfig?.name ?? getMessage("readiness.dataset.empty"), + }); + } + + const hasWorkflow = Boolean(job.workflow?.subworkflows?.length); + steps.push({ + id: "workflow", + label: getMessage("readiness.workflow.label"), + state: hasWorkflow ? "complete" : "empty", + summary: describeWorkflow(job.workflow), + }); + + const hasCompute = Boolean(job.compute?.cluster?.fqdn); + const violations = hasCompute ? getComputeLimitViolations(job.compute, clusterMetadata) : []; + steps.push({ + id: "compute", + label: getMessage("readiness.compute.label"), + state: hasCompute && !violations.length ? "complete" : "attention", + summary: violations.length ? violations.join(" · ") : describeCompute(job.compute), + }); + + return steps; +} + +/** + * A configuration the cluster will reject is a blocker too, and the reader + * should learn that from the Submit button rather than from the preflight after + * they have decided they are done. Sits with the other compute blocker, ahead of + * "Save the job", which is the one fixed without leaving the header. + */ +function withLimitBlocker( + blockers: string[], + steps: ReadinessStep[], + hasCluster: boolean, +): string[] { + const computeStep = steps.find((step) => step.id === "compute"); + if (!hasCluster || computeStep?.state !== "attention") return blockers; + + const limitBlocker = getMessage("blocker.computeLimits"); + const saveIndex = blockers.indexOf(getMessage("blocker.save")); + if (saveIndex === -1) return [...blockers, limitBlocker]; + + return [...blockers.slice(0, saveIndex), limitBlocker, ...blockers.slice(saveIndex)]; +} + +function getReviewState({ + editable, + isSubmittable, +}: { + editable: boolean; + isSubmittable: boolean; +}): ReadinessState { + // A read-only draft is somebody else's to submit; saying "ready" would invite + // an action this reader cannot take. + if (!editable) return "unavailable"; + + return isSubmittable ? "complete" : "empty"; +} + +function getReviewSummary({ + editable, + isSubmittable, + blockingReasons, +}: { + editable: boolean; + isSubmittable: boolean; + blockingReasons: string[]; +}): string { + if (!editable) return getMessage("readiness.review.viewOnly"); + if (isSubmittable) return getMessage("readiness.review.ready"); + + return blockingReasons.length === 1 + ? getMessage("readiness.review.oneRemaining") + : getMessage("readiness.review.remaining", { count: blockingReasons.length }); +} + +export function getJobReadiness({ + job, + materials = [], + isUsingMaterials = true, + datasetConfig = null, + editable = true, + clusterMetadata = [], +}: JobReadinessOptions): JobReadiness { + const parentJobName = (() => { + try { + return job.getParentJobClient?.()?.name ?? undefined; + } catch { + return undefined; + } + })(); + + const isDraft = Boolean(job.isInInitialStatus); + const isRunOrFinished = !isDraft; + + const steps = getCreationSteps({ + job, + materials, + isUsingMaterials, + datasetConfig, + parentJobName, + clusterMetadata, + }); + + const blockingReasons = withLimitBlocker( + getSubmitBlockers({ job, materials, isUsingMaterials }), + steps, + Boolean(job.compute?.cluster?.fqdn), + ); + + if (isRunOrFinished) { + // The job is out of the reader's hands: the creation steps are now a record + // of what ran, and what matters is what it is doing. + steps.push({ + id: "results", + label: getMessage( + job.isInFinalStatus ? "readiness.results.label" : "readiness.monitor.label", + ), + state: "complete", + summary: getMessage( + job.isInFinalStatus ? "readiness.results.summary" : "readiness.monitor.running", + ), + }); + steps.push({ + id: "files", + label: getMessage("readiness.files.label"), + state: "complete", + summary: getMessage("readiness.files.summary"), + }); + + return { steps, isSubmittable: false, blockingReasons: [], isRunOrFinished }; + } + + const isSubmittable = editable && blockingReasons.length === 0; + + steps.push({ + id: REVIEW_STEP_ID, + label: getMessage("readiness.review.label"), + state: getReviewState({ editable, isSubmittable }), + summary: getReviewSummary({ editable, isSubmittable, blockingReasons }), + }); + + return { steps, isSubmittable, blockingReasons, isRunOrFinished }; +} diff --git a/src/jobSubmission.ts b/src/jobSubmission.ts new file mode 100644 index 0000000..59cdb83 --- /dev/null +++ b/src/jobSubmission.ts @@ -0,0 +1,95 @@ +/** + * What still stands between a job and being submitted. + * + * Submit used to be one item among several in a dropdown, shown or hidden by a + * single `job.id && job.isInInitialStatus` check. Hidden is the worst of the + * three states a control can be in: the reader cannot act on it and is not told + * why. Promoting Submit to the header means it is always visible, which in turn + * means it has to be able to explain itself when it is not available. + * + * Kept as a pure function of the job so it can be unit-tested and reused: the + * readiness rail and the submit preflight (SOF-8023 phases 2.1 and 2.4) need the + * same answer, and the three must never disagree about whether a job is ready. + */ + +import { getMessage } from "./messages"; + +/** Minimal shape this module needs; the real entity is jode's `Job`. */ +export interface SubmittableJob { + id?: string; + isInInitialStatus?: boolean; + isInRunningStatus?: boolean; + workflow?: { + subworkflows?: unknown[]; + isUsingDataset?: boolean; + }; + compute?: { cluster?: { fqdn?: string } } | null; +} + +export interface SubmitBlockersOptions { + job: SubmittableJob; + /** Materials currently attached; empty for dataset-driven jobs. */ + materials?: unknown[]; + /** False for dataset jobs, where materials are not the input. */ + isUsingMaterials?: boolean; +} + +/** + * Reasons the job cannot be submitted, in the order a reader would fix them. + * Empty means ready. Each string is shown to the reader verbatim, so it names + * what to do, not what is wrong internally. + */ +export function getSubmitBlockers({ + job, + materials = [], + isUsingMaterials = true, +}: SubmitBlockersOptions): string[] { + const blockers: string[] = []; + + if (isUsingMaterials && materials.length === 0) { + blockers.push(getMessage("blocker.material")); + } + + if (!job.workflow?.subworkflows?.length) { + blockers.push(getMessage("blocker.workflow")); + } + + if (!job.compute?.cluster?.fqdn) { + blockers.push(getMessage("blocker.compute")); + } + + // Last, because it is the one the reader fixes by pressing the button next + // to Submit rather than by going somewhere else. + if (!job.id) { + blockers.push(getMessage("blocker.save")); + } + + return blockers; +} + +export function isJobSubmittable(options: SubmitBlockersOptions): boolean { + return Boolean(options.job.isInInitialStatus) && getSubmitBlockers(options).length === 0; +} + +/** + * One line for a disabled Submit button. Names the first thing to fix and how + * much else is waiting, rather than listing everything in a tooltip nobody + * reads to the end. + * + * Takes the list rather than the job so the button can be driven by + * `getJobReadiness`, which knows about blockers this module cannot see — cluster + * limits come from host-injected metadata, and a Submit button that stayed + * enabled over a preflight that refuses would be the designer contradicting + * itself. + */ +export function formatBlockedReason(blockers: string[]): string | null { + if (blockers.length === 0) return null; + if (blockers.length === 1) return blockers[0]; + + return getMessage("blocker.more", { first: blockers[0], count: blockers.length - 1 }); +} + +/** The same line, for callers holding a job rather than a readiness report. */ +export function getSubmitBlockedReason(options: SubmitBlockersOptions): string | null { + return formatBlockedReason(getSubmitBlockers(options)); +} diff --git a/src/materialSummary.ts b/src/materialSummary.ts new file mode 100644 index 0000000..9b147e5 --- /dev/null +++ b/src/materialSummary.ts @@ -0,0 +1,139 @@ +/** + * The facts about a material worth putting next to the 3D viewer. + * + * The Materials tab is a full-bleed canvas today: it shows the structure and + * nothing else, so the things a reader checks before running a job — what + * formula this actually is, which lattice, how many atoms, where it came from — + * are only available by leaving the designer. + * + * Deliberately defensive. `MaterialTab` already renders a fallback for hosts + * that pass something other than a made `Material` (the standalone demo used + * to), and model getters can throw on partial configs. A metadata panel that + * takes the page down with it would be worse than no panel, so every field is + * read through {@link readSafely} and simply omitted when unavailable. + */ + +import { getMessage } from "./messages"; + +export interface MaterialSource { + id: string; + name?: string; + url?: string; +} + +export interface MaterialSummary { + name?: string; + /** Unit-cell formula where known ("Si2"), otherwise the reduced one ("Si"). */ + formula?: string; + latticeType?: string; + /** Pre-formatted lattice constants, e.g. "a = 3.867 Å" or "a 3.87 · b 3.87 · c 5.02 Å". */ + latticeParameters?: string; + /** Pre-formatted angles, omitted when the cell is a right prism. */ + latticeAngles?: string; + atomCount?: number; + spaceGroup?: string; + source?: MaterialSource; +} + +function readSafely(read: () => T | undefined): T | undefined { + try { + return read(); + } catch { + return undefined; + } +} + +const ANGSTROM = "Å"; +const MIDDLE_DOT = "·"; +/** Lattice constants agreeing to this many decimals are treated as one value. */ +const LATTICE_EQUALITY_TOLERANCE = 1e-4; + +function roundToDecimals(value: number, decimals = 3): number { + const factor = 10 ** decimals; + return Math.round(value * factor) / factor; +} + +function formatLatticeParameters(lattice: any): string | undefined { + const { a, b, c } = lattice ?? {}; + if (![a, b, c].every((value) => typeof value === "number" && Number.isFinite(value))) { + return undefined; + } + + const isCubicCell = + Math.abs(a - b) < LATTICE_EQUALITY_TOLERANCE && + Math.abs(a - c) < LATTICE_EQUALITY_TOLERANCE; + + // Repeating one number three times says less than showing it once. + if (isCubicCell) return `a = ${roundToDecimals(a)} ${ANGSTROM}`; + + return `a ${roundToDecimals(a)} ${MIDDLE_DOT} b ${roundToDecimals( + b, + )} ${MIDDLE_DOT} c ${roundToDecimals(c)} ${ANGSTROM}`; +} + +function formatLatticeAngles(lattice: any): string | undefined { + const { alpha, beta, gamma } = lattice ?? {}; + if (![alpha, beta, gamma].every((v) => typeof v === "number" && Number.isFinite(v))) { + return undefined; + } + + // 90/90/90 is the unremarkable case; saying so spends a row to say nothing. + const isRightPrism = [alpha, beta, gamma].every( + (angle) => Math.abs(angle - 90) < LATTICE_EQUALITY_TOLERANCE, + ); + if (isRightPrism) return undefined; + + return `${roundToDecimals(alpha, 1)}° ${MIDDLE_DOT} ${roundToDecimals( + beta, + 1, + )}° ${MIDDLE_DOT} ${roundToDecimals(gamma, 1)}°`; +} + +function getAtomCount(material: any): number | undefined { + const count = readSafely( + () => material?.Basis?.elements?.length ?? material?.basis?.elements?.length, + ); + + return typeof count === "number" ? count : undefined; +} + +function getSpaceGroup(material: any): string | undefined { + const derivedProperties = readSafely(() => material?.derivedProperties) ?? []; + const symmetry = Array.isArray(derivedProperties) + ? derivedProperties.find((property: any) => property?.name === "symmetry") + : undefined; + + return symmetry?.spaceGroupSymbol ?? undefined; +} + +function getSource(material: any): MaterialSource | undefined { + const external = readSafely(() => material?.external ?? material?._json?.external); + if (!external?.id) return undefined; + + return { id: String(external.id), name: external.source, url: external.url }; +} + +export function getMaterialSummary(material: any): MaterialSummary { + const lattice = readSafely(() => material?.lattice); + + return { + name: readSafely(() => material?.name), + formula: readSafely(() => material?.unitCellFormula) || readSafely(() => material?.formula), + latticeType: readSafely(() => lattice?.type), + latticeParameters: formatLatticeParameters(lattice), + latticeAngles: formatLatticeAngles(lattice), + atomCount: getAtomCount(material), + spaceGroup: getSpaceGroup(material), + source: getSource(material), + }; +} + +/** + * The consequence of a multi-material selection, stated rather than implied. + * A materials set silently turns one job into N, which the designer never says. + */ +export function getBatchDescription(materialCount: number): string { + if (materialCount <= 1) return getMessage("materials.runsOnce"); + + return getMessage("materials.runsPerMaterial", { count: materialCount }); +} diff --git a/src/messages.ts b/src/messages.ts new file mode 100644 index 0000000..32d0497 --- /dev/null +++ b/src/messages.ts @@ -0,0 +1,160 @@ +import { getInjectedDeps } from "./setDependencies"; + +/** + * Every sentence the guided designer says, in one place. + * + * The webapp localizes through TAPi18n; this package cannot import it, and a + * string typed inline in a component is a string no translator will ever find. + * So each one lives here with an English default and a key, and the host + * substitutes its own resolver through `setDependencies({ translate })`. + * + * Interpolation matters more than it looks. "3 materials — runs 3 times" is not + * a sentence a catalogue can hold as a fragment plus a number: languages put the + * count in different places and inflect the noun differently. Every message that + * varies takes named parameters and stays one string, so a translation can move + * them. + * + * Keys are grouped by where they are said, not by the component that says them — + * the same readiness summary appears in the rail, the context strip and the + * preflight, and it must read identically in all three. + */ + +export type MessageParams = Record; + +/** Resolves a key to a localized string. Returns undefined to fall back. */ +export type TranslateFunction = (key: string, params?: MessageParams) => string | undefined; + +export const MESSAGES = { + // Readiness steps — the rail's labels and one-line summaries. + "readiness.material.label": "Material", + "readiness.material.empty": "No material selected", + "readiness.material.fromParent": "From parent job {name}", + "readiness.material.single": "1 material", + "readiness.material.batch": "{count} materials — runs {count} times", + "readiness.dataset.label": "Dataset", + "readiness.dataset.empty": "No dataset selected", + "readiness.workflow.label": "Workflow", + "readiness.workflow.empty": "No workflow selected", + "readiness.workflow.withUnits": "{name} · {count} units", + "readiness.workflow.subworkflowCount": "{count} subworkflows", + "readiness.compute.label": "Compute", + "readiness.compute.empty": "Cluster and resources needed", + "readiness.compute.overNodes": "over the {limit}-node limit", + "readiness.compute.overPpn": "over {limit} cores per node", + "readiness.compute.overWalltime": "over the {limit} h queue limit", + "readiness.review.label": "Review & submit", + "readiness.review.ready": "Ready to submit", + "readiness.review.viewOnly": "View only", + "readiness.review.oneRemaining": "1 step remaining", + "readiness.review.remaining": "{count} steps remaining", + "readiness.monitor.label": "Monitor", + "readiness.monitor.running": "Running", + "readiness.results.label": "Results", + "readiness.results.summary": "Outputs and properties", + "readiness.files.label": "Files", + "readiness.files.summary": "Job directory", + + // The rail's per-step affordance for opening its "Select …" dialog. + "rail.choose": "Choose", + "rail.change": "Change", + + // Why Submit is disabled. Read by the button's tooltip and the preflight. + "blocker.material": "Select a material", + "blocker.dataset": "Select a dataset", + "blocker.workflow": "Select a workflow", + "blocker.compute": "Configure compute", + "blocker.computeLimits": "Bring compute within the cluster's limits", + "blocker.save": "Save the job", + "blocker.more": "{first} (+{count} more)", + + // Preflight rows. + "preflight.title": "Preflight", + "preflight.running": "Running checks…", + "preflight.allPassed": "All checks passed", + "preflight.oneProblem": "1 problem to fix", + "preflight.problems": "{count} problems to fix", + "preflight.oneWarning": "1 warning to acknowledge", + "preflight.warnings": "{count} warnings to acknowledge", + "preflight.noChecks": "No checks ran.", + "preflight.back": "Back to designer", + "preflight.rerun": "Re-run checks", + "preflight.submit": "Submit job", + "preflight.details": "Details", + "preflight.acknowledge": "Acknowledge", + "preflight.acknowledged": "Acknowledged", + "preflight.checkFailed": "This check could not run", + "preflight.inputs.datasetOk": "Dataset job — materials not required", + "preflight.inputs.chooseDataset": "Choose a dataset", + "preflight.inputs.chooseMaterial": "Choose a material", + "preflight.inputs.batch": "{count} materials — the workflow runs {count} times", + "preflight.workflow.label": "Workflow renders", + "preflight.workflow.noRenderer": "{count} units — rendering not available here", + "preflight.workflow.failed": "A unit's input template failed to render", + "preflight.workflow.open": "Open the workflow", + "preflight.workflow.ok": "{count} units · all input templates render", + "preflight.compute.label": "Compute within limits", + "preflight.compute.noCluster": "No cluster selected", + "preflight.compute.adjust": "Adjust compute", + "preflight.compute.noLimits": "{cluster} · {resources} — no published limits to check against", + "preflight.compute.overNodes": "{nodes} nodes exceeds the {limit}-node limit", + "preflight.compute.overPpn": "{ppn} cores per node exceeds the limit of {limit}", + "preflight.compute.overWalltime": "walltime {walltime} exceeds the queue limit of {limit}", + "preflight.budget.label": "Budget", + "preflight.budget.incomplete": "Set nodes, cores and a walltime to estimate the cost", + "preflight.budget.overQuota": "{usage} — only {remaining} left this month", + "preflight.budget.mostOfQuota": "{usage} — more than half of the remaining {remaining}", + "preflight.budget.remainingAfter": "{usage} — {left} would remain", + "preflight.budget.reduce": "Reduce resources", + "preflight.budget.wouldRemain": "{left} would be left after this job.", + "preflight.saved.label": "Saved", + "preflight.saved.ok": "Job is saved", + "preflight.saved.never": "The job has never been saved", + + // Save state. + "saveState.saved": "All changes saved", + "saveState.unsaved": "Unsaved changes", + "saveState.saving": "Saving…", + + // Materials tray and metadata. + "materials.runsOnce": "The workflow runs once.", + "materials.runsPerMaterial": + "{count} materials — the workflow runs {count} times, once per material.", +} as const; + +export type MessageKey = keyof typeof MESSAGES; + +/** + * Fills `{name}` placeholders. Unknown placeholders are left alone rather than + * blanked: a translation that names a parameter this call site does not provide + * should show the gap, not silently swallow it. + */ +function interpolate(template: string, params?: MessageParams): string { + if (!params) return template; + + return template.replace(/\{(\w+)\}/g, (match, name: string) => + name in params ? String(params[name]) : match, + ); +} + +/** + * The localized string for a key. + * + * Falls back to English whenever the host has no resolver, or its resolver + * returns nothing for this key — a missing translation must show the sentence, + * never the key. + */ +export function getMessage(key: MessageKey, params?: MessageParams): string { + const { translate } = getInjectedDeps() as { translate?: TranslateFunction }; + + if (typeof translate === "function") { + try { + const translated = translate(key, params); + if (translated) return interpolate(translated, params); + } catch { + // A host resolver that throws must not take the designer's copy with + // it; English is always available. + } + } + + return interpolate(MESSAGES[key], params); +} diff --git a/src/preflight/checks.ts b/src/preflight/checks.ts new file mode 100644 index 0000000..0b91bbd --- /dev/null +++ b/src/preflight/checks.ts @@ -0,0 +1,345 @@ +import { + estimateComputeUsage, + findClusterMetadata, + formatCoreHours, + formatCost, + parseWalltimeHours, +} from "../computeEstimate"; +import { getMaterialSummary } from "../materialSummary"; +import { getMessage } from "../messages"; +import type { PreflightCheck, PreflightRow } from "./types"; + +/** + * The checks that run before a job is submitted, in the order a reader would + * work through them: what it runs on, what it runs, where it runs, what it + * costs. + * + * Each is a pure-ish async function of the preflight context. They are separate + * exports rather than one big function so the host can drop one or slot its own + * between them (see `runPreflightChecks`), and so each can be unit-tested with + * the smallest possible context. + * + * The rule they all follow: a check that cannot be judged returns `skip`, never + * `pass`. A dialog that says "Budget — fine" when it has never seen a price is + * worse than one that says it does not know. + */ + +function capitalise(text: string): string { + return text.charAt(0).toUpperCase() + text.slice(1); +} + +function formatHours(hours: number): string { + const rounded = Math.round(hours * 10) / 10; + + return `${rounded} h`; +} + +/** The job runs on something: a material, a set of them, or a dataset. */ +export const checkInputs: PreflightCheck = async ({ + job, + materials = [], + isUsingMaterials = true, +}) => { + if (!isUsingMaterials) { + const hasDataset = Boolean(job.workflow?.isUsingDataset); + + return { + id: "inputs", + label: getMessage("readiness.dataset.label"), + state: hasDataset ? "pass" : "fail", + detail: getMessage( + hasDataset ? "preflight.inputs.datasetOk" : "readiness.dataset.empty", + ), + fix: hasDataset + ? undefined + : { label: getMessage("preflight.inputs.chooseDataset"), stepId: "dataset" }, + }; + } + + if (materials.length === 0) { + return { + id: "inputs", + label: getMessage("readiness.material.label"), + state: "fail", + detail: getMessage("readiness.material.empty"), + fix: { label: getMessage("preflight.inputs.chooseMaterial"), stepId: "material" }, + }; + } + + if (materials.length > 1) { + return { + id: "inputs", + label: getMessage("readiness.material.label"), + state: "pass", + // The multiplier is the thing a reader most often does not expect at + // submit time, so it is the thing the row says. + detail: getMessage("preflight.inputs.batch", { count: materials.length }), + }; + } + + const summary = getMaterialSummary(materials[0]); + const atoms = summary.atomCount === undefined ? undefined : `${summary.atomCount} atoms`; + + return { + id: "inputs", + label: getMessage("readiness.material.label"), + state: "pass", + detail: + [summary.formula ?? summary.name ?? getMessage("readiness.material.single"), atoms] + .filter(Boolean) + .join(" · ") || getMessage("readiness.material.single"), + }; +}; + +/** + * The workflow renders. This is the one check that touches the entity: rendering + * is how template errors surface at all, and it is what submission would do a + * moment later anyway — better to find out here, with a row pointing at the + * workflow, than in a failed run. + */ +export const checkWorkflowRenders: PreflightCheck = async ({ job }) => { + const subworkflows = job.workflow?.subworkflows ?? []; + + if (!subworkflows.length) { + return { + id: "workflow", + label: getMessage("preflight.workflow.label"), + state: "fail", + detail: getMessage("readiness.workflow.empty"), + fix: { label: getMessage("blocker.workflow"), stepId: "workflow" }, + }; + } + + const unitCount = subworkflows.reduce( + (total: number, subworkflow: any) => total + (subworkflow?.units?.length ?? 0), + 0, + ); + + if (typeof job.render !== "function") { + return { + id: "workflow", + label: getMessage("preflight.workflow.label"), + state: "skip", + detail: getMessage("preflight.workflow.noRenderer", { count: unitCount }), + }; + } + + try { + job.render(); + } catch (error) { + return { + id: "workflow", + label: getMessage("preflight.workflow.label"), + state: "fail", + detail: getMessage("preflight.workflow.failed"), + explanation: error instanceof Error ? error.message : String(error), + fix: { label: getMessage("preflight.workflow.open"), stepId: "workflow" }, + }; + } + + return { + id: "workflow", + label: getMessage("preflight.workflow.label"), + state: "pass", + detail: getMessage("preflight.workflow.ok", { count: unitCount }), + }; +}; + +/** + * Compute is set, and within the cluster's limits when the host told us what + * they are. Without metadata this can still say whether a cluster and resources + * were chosen — that part needs no host data. + */ +export const checkComputeLimits: PreflightCheck = async ({ job, clusterMetadata = [] }) => { + const { compute } = job; + const clusterName = compute?.cluster?.fqdn; + + if (!clusterName) { + return { + id: "compute", + label: getMessage("preflight.compute.label"), + state: "fail", + detail: getMessage("preflight.compute.noCluster"), + fix: { label: getMessage("blocker.compute"), stepId: "compute" }, + }; + } + + const walltimeHours = parseWalltimeHours(compute?.timeLimit); + const resources = `${compute?.nodes ?? "?"}×${compute?.ppn ?? "?"}`; + const limits = findClusterMetadata(compute, clusterMetadata)?.limits; + + if (!limits) { + return { + id: "compute", + label: getMessage("preflight.compute.label"), + state: "skip", + detail: getMessage("preflight.compute.noLimits", { + cluster: clusterName, + resources, + }), + }; + } + + const violations: string[] = []; + if (limits.maxNodes !== undefined && (compute?.nodes ?? 0) > limits.maxNodes) { + violations.push( + getMessage("preflight.compute.overNodes", { + nodes: compute.nodes, + limit: limits.maxNodes, + }), + ); + } + if (limits.maxPpn !== undefined && (compute?.ppn ?? 0) > limits.maxPpn) { + violations.push( + getMessage("preflight.compute.overPpn", { ppn: compute.ppn, limit: limits.maxPpn }), + ); + } + if ( + limits.maxWalltimeHours !== undefined && + walltimeHours !== undefined && + walltimeHours > limits.maxWalltimeHours + ) { + violations.push( + getMessage("preflight.compute.overWalltime", { + walltime: formatHours(walltimeHours), + limit: formatHours(limits.maxWalltimeHours), + }), + ); + } + + if (violations.length) { + return { + id: "compute", + label: getMessage("preflight.compute.label"), + state: "fail", + detail: capitalise(violations[0]), + explanation: violations.length > 1 ? violations.map(capitalise).join(". ") : undefined, + fix: { label: getMessage("preflight.compute.adjust"), stepId: "compute" }, + }; + } + + const walltime = walltimeHours === undefined ? undefined : formatHours(walltimeHours); + + return { + id: "compute", + label: getMessage("preflight.compute.label"), + state: "pass", + detail: [clusterName, resources, walltime].filter(Boolean).join(" · "), + }; +}; + +/** + * What the run will consume against what is left. Skips rather than passes when + * the host injected no quota — most deployments have none, and a green "Budget" + * row backed by nothing would be a lie the reader has no way to check. + */ +export const checkBudget: PreflightCheck = async ({ + job, + materials = [], + isUsingMaterials = true, + clusterMetadata = [], + quota, +}) => { + const runs = isUsingMaterials ? Math.max(materials.length, 1) : 1; + const estimate = estimateComputeUsage(job.compute, clusterMetadata, runs); + + if (estimate.coreHours === undefined) { + return { + id: "budget", + label: getMessage("preflight.budget.label"), + state: "skip", + detail: getMessage("preflight.budget.incomplete"), + }; + } + + const usage = [ + formatCoreHours(estimate.coreHours), + formatCost(estimate.cost, estimate.currency), + ] + .filter(Boolean) + .join(" ≈ "); + const remaining = quota?.remainingCoreHours; + + if (remaining === undefined) { + return { + id: "budget", + label: getMessage("preflight.budget.label"), + state: "skip", + detail: usage, + }; + } + + if (estimate.coreHours > remaining) { + return { + id: "budget", + label: getMessage("preflight.budget.label"), + state: "fail", + detail: getMessage("preflight.budget.overQuota", { + usage, + remaining: formatCoreHours(remaining) ?? "", + }), + fix: { label: getMessage("preflight.budget.reduce"), stepId: "compute" }, + }; + } + + const left = remaining - estimate.coreHours; + // A run that eats most of what is left is worth stopping on, but it is the + // account holder's call, not ours — hence warn, which they can acknowledge. + if (estimate.coreHours > remaining / 2) { + return { + id: "budget", + label: getMessage("preflight.budget.label"), + state: "warn", + detail: getMessage("preflight.budget.mostOfQuota", { + usage, + remaining: formatCoreHours(remaining) ?? "", + }), + explanation: getMessage("preflight.budget.wouldRemain", { + left: formatCoreHours(left) ?? "", + }), + }; + } + + return { + id: "budget", + label: getMessage("preflight.budget.label"), + state: "pass", + detail: getMessage("preflight.budget.remainingAfter", { + usage, + left: formatCoreHours(left) ?? "", + }), + }; +}; + +/** + * The job exists server-side. Last, because unlike the others it is fixed with + * the button next to Submit rather than by going to a step. + */ +export const checkSaved: PreflightCheck = async ({ job }) => { + if (job.id) { + return { + id: "saved", + label: getMessage("preflight.saved.label"), + state: "pass", + detail: getMessage("preflight.saved.ok"), + }; + } + + return { + id: "saved", + label: getMessage("preflight.saved.label"), + state: "fail", + detail: getMessage("preflight.saved.never"), + fix: { label: getMessage("blocker.save"), stepId: "review" }, + }; +}; + +export const DEFAULT_PREFLIGHT_CHECKS: PreflightCheck[] = [ + checkInputs, + checkWorkflowRenders, + checkComputeLimits, + checkBudget, + checkSaved, +]; + +export type { PreflightRow }; diff --git a/src/preflight/index.ts b/src/preflight/index.ts new file mode 100644 index 0000000..7138545 --- /dev/null +++ b/src/preflight/index.ts @@ -0,0 +1,22 @@ +export { + checkBudget, + checkComputeLimits, + checkInputs, + checkSaved, + checkWorkflowRenders, + DEFAULT_PREFLIGHT_CHECKS, +} from "./checks"; +export { + canSubmitFromReport, + getPreflightChecks, + getReportSummary, + runPreflightChecks, +} from "./runPreflightChecks"; +export type { + PreflightCheck, + PreflightContext, + PreflightFix, + PreflightReport, + PreflightRow, + PreflightState, +} from "./types"; diff --git a/src/preflight/runPreflightChecks.ts b/src/preflight/runPreflightChecks.ts new file mode 100644 index 0000000..42983c6 --- /dev/null +++ b/src/preflight/runPreflightChecks.ts @@ -0,0 +1,106 @@ +import { getMessage } from "../messages"; +import { getInjectedDeps } from "../setDependencies"; +import { DEFAULT_PREFLIGHT_CHECKS } from "./checks"; +import type { PreflightCheck, PreflightContext, PreflightReport, PreflightRow } from "./types"; + +/** + * The default checks plus whatever the host injected. The webapp has checks + * job-designer cannot make on its own — account balance, licence entitlements — + * so it appends them through `setDependencies({ preflightChecks: [...] })`. + */ +export function getPreflightChecks(): PreflightCheck[] { + const injected = (getInjectedDeps() as { preflightChecks?: unknown }).preflightChecks; + const extra = Array.isArray(injected) + ? injected.filter((check): check is PreflightCheck => typeof check === "function") + : []; + + return [...DEFAULT_PREFLIGHT_CHECKS, ...extra]; +} + +async function runOne( + check: PreflightCheck, + context: PreflightContext, +): Promise { + try { + return await check(context); + } catch (error) { + return { + id: `check-error-${check.name || "anonymous"}`, + label: check.name || "Check", + state: "skip", + detail: getMessage("preflight.checkFailed"), + explanation: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * Runs the submit checks and reports what it found. + * + * Sequential rather than parallel, deliberately: `checkWorkflowRenders` mutates + * the job entity, so the checks after it must see the rendered state, and the + * rows appear in a fixed reading order either way. + * + * A check that throws does not take the report down with it — the reader gets a + * `skip` row naming the check that broke, and submission is not blocked by our + * own bug. Only a check that deliberately returns `fail` blocks. + */ +export async function runPreflightChecks( + context: PreflightContext, + checks: PreflightCheck[] = getPreflightChecks(), +): Promise { + const rows = await checks.reduce>(async (previous, check) => { + const collected = await previous; + const row = await runOne(check, context); + + return row ? [...collected, row] : collected; + }, Promise.resolve([])); + + return { + rows, + failures: rows.filter((row) => row.state === "fail").map((row) => row.id), + warnings: rows.filter((row) => row.state === "warn").map((row) => row.id), + }; +} + +/** + * Whether Submit may proceed: nothing failed, and every warning has been + * acknowledged. Kept beside the runner so the dialog's button and any + * programmatic caller cannot come to different conclusions. + */ +export function canSubmitFromReport( + report: PreflightReport | null, + acknowledged: ReadonlySet | string[] = [], +): boolean { + if (!report) return false; + if (report.failures.length) return false; + + const acknowledgedIds = Array.isArray(acknowledged) ? new Set(acknowledged) : acknowledged; + + return report.warnings.every((id) => acknowledgedIds.has(id)); +} + +/** One line for the dialog's header: what the report amounts to. */ +export function getReportSummary( + report: PreflightReport | null, + acknowledged: ReadonlySet | string[] = [], +): string { + if (!report) return getMessage("preflight.running"); + + const acknowledgedIds = Array.isArray(acknowledged) ? new Set(acknowledged) : acknowledged; + const failures = report.failures.length; + if (failures) { + return failures === 1 + ? getMessage("preflight.oneProblem") + : getMessage("preflight.problems", { count: failures }); + } + + const unacknowledged = report.warnings.filter((id) => !acknowledgedIds.has(id)).length; + if (unacknowledged) { + return unacknowledged === 1 + ? getMessage("preflight.oneWarning") + : getMessage("preflight.warnings", { count: unacknowledged }); + } + + return getMessage("preflight.allPassed"); +} diff --git a/src/preflight/types.ts b/src/preflight/types.ts new file mode 100644 index 0000000..16dc9a7 --- /dev/null +++ b/src/preflight/types.ts @@ -0,0 +1,59 @@ +import type { ClusterMetadata, ComputeQuota } from "../computeEstimate"; +import type { SubmittableJob } from "../jobSubmission"; + +export type PreflightState = + /** Nothing wrong. */ + | "pass" + /** Submittable, but the reader should know. Requires acknowledgement. */ + | "warn" + /** Blocks submission. */ + | "fail" + /** Could not be judged — no data, or the check itself failed. Never blocks. */ + | "skip"; + +/** Where a failing row sends the reader to fix it. */ +export interface PreflightFix { + label: string; + /** A `ReadinessStep.id`, so the rail and the dialog agree on where to land. */ + stepId: string; +} + +export interface PreflightRow { + id: string; + label: string; + state: PreflightState; + /** One line naming what was checked and what was found. */ + detail: string; + /** Longer explanation, shown behind "Details". */ + explanation?: string; + fix?: PreflightFix; +} + +export interface PreflightReport { + rows: PreflightRow[]; + /** Ids of `fail` rows, in the order to fix them. */ + failures: string[]; + /** Ids of `warn` rows, which must be acknowledged before submitting. */ + warnings: string[]; +} + +/** Everything a check may read. Checks must not reach outside it. */ +export interface PreflightContext { + job: SubmittableJob & { + name?: string; + compute?: any; + workflow?: any; + render?: () => void; + }; + materials?: any[]; + isUsingMaterials?: boolean; + clusterMetadata?: ClusterMetadata[]; + quota?: ComputeQuota | null; +} + +/** + * A check returns its row, or null to leave itself out of the report entirely + * (as opposed to `skip`, which says "this was considered and could not be + * judged"). Async so a host-injected check can ask a server about balance. + */ +export type PreflightCheck = (context: PreflightContext) => Promise; diff --git a/src/saveState.ts b/src/saveState.ts new file mode 100644 index 0000000..f5f62ef --- /dev/null +++ b/src/saveState.ts @@ -0,0 +1,58 @@ +/** + * Whether the job on screen matches the one that was persisted. + * + * The designer saves manually, but says nothing about whether it needs to: a + * job with unsaved edits looks exactly like a saved one, and closing the tab + * loses them silently. The guided-designer mockups show getMessage("saveState.saved") in + * the header — copy that would be worse than the current silence if it were not + * actually true, so this tracks the real thing. + * + * Explicitly *not* autosave. Persisting automatically is a product decision + * (UX-498) with its own backend implications; this only stops the interface + * from being quiet about state it already knows. + */ + +import { getMessage } from "./messages"; + +export type SaveState = "saved" | "unsaved" | "saving"; + +export function getSaveState({ + hasUnsavedChanges, + isSaving = false, +}: { + hasUnsavedChanges: boolean; + isSaving?: boolean; +}): SaveState { + if (isSaving) return "saving"; + + return hasUnsavedChanges ? "unsaved" : "saved"; +} + +const SAVE_STATE_LABELS: Record = { + saved: getMessage("saveState.saved"), + unsaved: getMessage("saveState.unsaved"), + saving: getMessage("saveState.saving"), +}; + +export function getSaveStateLabel(state: SaveState): string { + return SAVE_STATE_LABELS[state]; +} + +/** + * Whether leaving now would lose work. + * + * A read-only view has nothing to lose, and a job mid-save is already on its + * way to the server — warning in either case trains people to dismiss the + * dialog without reading it. + */ +export function shouldWarnBeforeLeaving({ + hasUnsavedChanges, + editable, + isSaving = false, +}: { + hasUnsavedChanges: boolean; + editable: boolean; + isSaving?: boolean; +}): boolean { + return Boolean(editable) && hasUnsavedChanges && !isSaving; +} diff --git a/src/standalone/index.tsx b/src/standalone/index.tsx index 90ece95..db39c43 100644 --- a/src/standalone/index.tsx +++ b/src/standalone/index.tsx @@ -67,6 +67,134 @@ function downloadJson(data: unknown, filename: string) { URL.revokeObjectURL(url); } +/** + * A queue as `ive`'s QueuesTable expects it: the webapp passes model instances, + * so the table reads `maxAvailableNodect`, `capacity`, `load` and calls + * `getETAClient()`. Plain objects without those crash the queue picker. + */ +function demoQueue({ + name, + displayName, + maxAvailableNodect, + load, + etaMinutes, +}: { + name: string; + displayName: string; + maxAvailableNodect: number; + load: number; + etaMinutes: number; +}) { + return { + name, + displayName, + maxAvailableNodect, + nodeLimit: maxAvailableNodect, + capacity: String(maxAvailableNodect), + load, + getETAClient: () => ({ display: `~${etaMinutes} min` }), + }; +} + +/** + * Clusters for the demo. The webapp fetches these; standalone had an empty list, + * which left the compute step unfillable and the estimate and preflight with + * nothing to judge — so the two states most worth reviewing could never be seen. + */ +const DEMO_CLUSTERS = [ + { + hostname: "cluster-007.exabyte.io", + name: "cluster-007", + displayName: "cluster-007", + isDefault: true, + queues: [ + demoQueue({ + name: "OR", + displayName: "on-demand regular", + maxAvailableNodect: 4, + load: 40, + etaMinutes: 8, + }), + demoQueue({ + name: "OF", + displayName: "on-demand fast", + maxAvailableNodect: 2, + load: 75, + etaMinutes: 2, + }), + demoQueue({ + name: "SR", + displayName: "spot regular", + maxAvailableNodect: 8, + load: 20, + etaMinutes: 45, + }), + ], + }, + { + hostname: "master-production-20160630-cluster-001.exabyte.io", + name: "cluster-001", + displayName: "cluster-001", + queues: [ + demoQueue({ + name: "OR", + displayName: "on-demand regular", + maxAvailableNodect: 2, + load: 60, + etaMinutes: 25, + }), + demoQueue({ + name: "D", + displayName: "debug", + maxAvailableNodect: 1, + load: 10, + etaMinutes: 1, + }), + ], + }, +]; + +/** Pricing, limits and queue waits the host would inject. Not part of the job. */ +const DEMO_CLUSTER_METADATA = [ + { + fqdn: "cluster-007.exabyte.io", + name: "cluster-007", + pricePerCoreHour: 0.08, + currency: "USD", + limits: { maxNodes: 4, maxPpn: 32, maxWalltimeHours: 12 }, + queueWaitMinutes: 8, + }, + { + fqdn: "master-production-20160630-cluster-001.exabyte.io", + name: "cluster-001", + pricePerCoreHour: 0.05, + currency: "USD", + limits: { maxNodes: 2, maxPpn: 16, maxWalltimeHours: 6 }, + queueWaitMinutes: 25, + }, +]; + +/** Fixed unix seconds so the simulated run reads the same on every reload. */ +const SIMULATED_START = 1_755_000_000; + +/** + * A dialog handle in the tuple shape the webapp passes. The demo has no entity + * explorer to open, so it says which dialog would have opened rather than + * silently doing nothing — otherwise a broken wiring looks exactly like a + * working one. + */ +function demoDialog(name: string): [(...args: unknown[]) => void, () => void] { + return [ + () => { + // eslint-disable-next-line no-alert + window.alert(`${name} — the webapp opens its entity explorer here.`); + }, + () => {}, + ]; +} + +const DEMO_QUOTA = { remainingCoreHours: 500, totalCoreHours: 1000, currency: "USD" }; + function App() { const allWorkflowJsons = useMemo(() => new WorkflowStandata().getAll() ?? [], []); const [workflowIndex, setWorkflowIndex] = useState(0); @@ -85,6 +213,16 @@ function App() { })), [], ); + // Phase 2 layout, opt-in: the demo is where it gets reviewed before any host flips it on. + const [useGuidedDesigner, setUseGuidedDesigner] = useState(true); + // A submitted job cannot be reached in the demo — its submit API is a stub — + // so the monitor, the lifecycle timeline past Draft, and the rail's Monitor + // step would never be reviewable. This starts the job already running. + const [isRunSimulated, setIsRunSimulated] = useState(false); + // The batch multiplier is the thing the plan says surprises readers most — + // "3 materials, the workflow runs 3 times" — and with a single material the + // tray copy, the rail summary and the ×3 estimate could never be reviewed. + const [isBatch, setIsBatch] = useState(false); const [materialIndex, setMaterialIndex] = useState(() => { const idx = allMaterialJsons.findIndex((m: any) => /silicon|^si\b/i.test(m.name ?? "")); return idx >= 0 ? idx : 0; @@ -94,6 +232,16 @@ function App() { [materialIndex, allMaterialJsons], ); + /** One material, or that one plus its two neighbours as a batch. */ + const selectedMaterials = useMemo(() => { + if (!isBatch) return [selectedMaterial]; + + return [0, 1, 2].map( + (offset) => + new Material(allMaterialJsons[(materialIndex + offset) % allMaterialJsons.length]), + ); + }, [isBatch, selectedMaterial, materialIndex, allMaterialJsons]); + const jobRef = useRef | null>(null); const job = useMemo(() => { @@ -107,7 +255,24 @@ function App() { // pre-submission status makes the header editable (name input + Save button), // matching how the webapp shows a new job - without it the demo header hides // the exact controls the designer is meant to demo. - const newJob = new Job({ name, status: "pre-submission" }); + // + // The `_id` stands in for a job the webapp would have persisted: the demo + // has no server, so `createOrUpdate` is a no-op and the job would never + // acquire one - leaving Submit permanently blocked on "Save the job" and + // the preflight unreachable. The save-state indicator is unaffected; it + // tracks edits, not identity. + const newJob = new Job({ + _id: "standalone-job-1", + name, + status: isRunSimulated ? "active" : "pre-submission", + statusTrack: isRunSimulated + ? [ + { status: "pre-submission", trackedAt: SIMULATED_START }, + { status: "submitted", trackedAt: SIMULATED_START + 60 }, + { status: "active", trackedAt: SIMULATED_START + 180 }, + ] + : [], + }); newJob.setWorkflow(wodeWorkflow); newJob.setMaterial(selectedMaterial); @@ -127,7 +292,7 @@ function App() { return null; } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [wodeWorkflow, selectedMaterial]); + }, [wodeWorkflow, selectedMaterial, isRunSimulated]); if (job) jobRef.current = job; @@ -142,7 +307,9 @@ function App() { downloadJson(raw, safeFilename); }; - const designerKey = `${workflowIndex}-${materialIndex}`; + // Remount on a simulated-run flip too: the container builds its redux store from + // the job it is first given, so a new Job instance alone would not be picked up. + const designerKey = `${workflowIndex}-${materialIndex}-${isRunSimulated}-${isBatch}`; if (!wodeWorkflow || !selectedMaterial || !job) { return ( @@ -220,6 +387,31 @@ function App() { + + + +