diff --git a/.workhorse/plans/g1/plan.md b/.workhorse/plans/g1/plan.md deleted file mode 100644 index d6eb10b..0000000 --- a/.workhorse/plans/g1/plan.md +++ /dev/null @@ -1,71 +0,0 @@ -# G1 · Display compute requirements in form submission results - -## Goal - -The finalised artifact and its PDF should present concrete compute requirements -(processor, memory, storage, network, OS/software) for each server and device -class actually present in the deployment. The numbers are the authoritative -recommended base-level specs from the Tamanu "Compute resource recommendations" -reference; which classes appear is driven by the answers. - -## Design - -Keep the ruleset as data. Add a `requirements` block to the ruleset, evaluated -by the engine exactly like `rules`: each entry has a `when` condition and a -requirement profile (server/device class, a short who-provisions summary, a list -of spec rows, an optional note). The engine emits the union of triggered -requirements; the artifact renders them in a new "Compute requirements" section. - -Requirements surface only for classes someone must act on / provision: - -- **Central server** — when client-hosted (`central == clienthosted`). BES-cloud - Central is provisioned by BES, so it carries no client-facing requirement. -- **Facility server** — when client-hosted facilities are present and not every - site runs an Iti (`hosting_where` allclient/mix AND `iti_use != all`). -- **Tamanu Iti mini-server** — when `iti_use` is some/all. BES-built; only its - network needs stating. -- **User devices (workstations)** — always. The client provides these regardless - of hosting. -- **Mobile devices** — when mobile users are in play (`mobile` m1/m2/m3). - -Numbers are the recommended base level from the reference doc. Per-size scaling -is deliberately out of scope: the doc gives one base tier and says higher tiers -are advised separately by BES, and inventing per-band figures would state costs -nobody has confirmed. The size band already appears in the artifact header. - -## Steps - -- [x] Add `Requirement` + `Spec` to the ruleset model; `requirements` on `Ruleset` (serde default) -- [x] Validate requirement id uniqueness in `Ruleset::validate()` -- [x] Emit `requirements` (`TriggeredRequirement`) from the engine `evaluate()` -- [x] Export the new types from `ruleset/mod.rs` -- [x] Author the `requirements` block in `ruleset.ron` with the reference specs -- [x] Regenerate `web/openapi.json` + `api-types.ts`; add wire type re-exports -- [x] Render a "Compute requirements" section in `Artifact.tsx` (+ CSS) -- [x] Update the WIZ spec: outputs, PDF ordering, engine model note -- [x] Rust engine tests for presence gating; extend `tests/ruleset.rs` -- [x] Create `.workhorse/test-cases/g1/overview.md` -- [x] `just check`, `just test`, frontend typecheck - -## Refinement: size-scaling (after the price-list resource) - -The price list gives authoritative per-band server specs, so compute requirements -now scale with the derived size band instead of showing one baseline tier. - -- Server profiles (Central, Facility) carry size-invariant rows in `specs` - (network, OS) plus per-band rows in `by_size` (processor, memory, storage). The - engine resolves `by_size` against `derived["size"]` and leads with those rows. -- Figures follow the recommended on-prem tower tiers (small 2c/16GB/480GB, - medium 4c/16GB/960GB, large 8c/32GB/2TB); Tiny reuses small figures plus an - advisory to host with BES or use an Iti rather than buy a server. Make is a - suggestion, not a requirement. -- The Iti profile now states its one-model hardware spec (4c/8GB/500GB SSD). -- Only client-provisioned classes appear (what they'd buy to self-host); BES-cloud - servers stay off — confirmed with the user. -- Cost/pricing deferred: user chose "cost tier only" and will return to pricing. - A per-server tier would just restate the deployment size band, so no cost rows - this pass. Wire type and frontend unchanged (size resolved server-side). - -- [x] Model: `by_size`/`SizeSpecs`, validation, engine resolution, ruleset rework -- [x] Tests for size-scaling + Tiny advisory; spec updated to match -- [x] `cargo test`, clippy, frontend build, playwright e2e all green diff --git a/.workhorse/specs/wizard/onboarding.md b/.workhorse/specs/wizard/onboarding.md index 9792a1a..1ff1fa2 100644 --- a/.workhorse/specs/wizard/onboarding.md +++ b/.workhorse/specs/wizard/onboarding.md @@ -264,6 +264,12 @@ So the central server appears only when the client hosts it; a facility server a A server's processor, memory and storage scale with the deployment's derived size band, drawn from the recommended per-band figures; its network row is the same at every size. A row may instead be tied to an answer, so the operating system row names the platform the reader chose rather than listing what is available. + +A block can carry hints beneath its rows, and a hint may be tied to an answer the same way. +Guidance about how to provision the figures belongs here rather than among the actions, because it qualifies the figures themselves: that a virtual machine may sit slightly under them where its host has room to grow, or that on physical hardware they are a minimum worth exceeding, since it cannot be resized later. +A hint says which direction it departs in, since "smaller" alone would leave a reader unsure whether the figures were the target or the starting point. +Each appears only for the way of provisioning it describes, and a deployment with both kinds gets both. +Such a hint states the guidance without restating the figures, which would contradict the rows above it. Working out what is needed is the tool's job, so the requirements state it plainly rather than hedging that a larger deployment might need more. The make of a server is a suggestion, never a requirement: the block leads with the specification a server must meet, not a product to buy. For the smallest deployments the block advises hosting with BES or using a mini-server rather than buying a server at all, since dedicated hardware rarely pays off at that scale. diff --git a/.workhorse/test-cases/g1/overview.md b/.workhorse/test-cases/g1/overview.md deleted file mode 100644 index 377f447..0000000 --- a/.workhorse/test-cases/g1/overview.md +++ /dev/null @@ -1,34 +0,0 @@ -# G1 · Compute requirements in results - -Scenarios verifying the artifact presents compute requirements for the classes a -deployment actually uses, drawn from the ruleset (verifies spec: WIZ, Compute -requirements). - -## Engine: which classes surface - -- [x] Default path (BES-cloud Central, all-client facilities, no mobile) surfaces facility server and user devices, but not Central, Iti, or mobile -- [x] A fully BES-cloud deployment with no mobile surfaces only user devices -- [x] Client-hosted Central surfaces the Central server requirement -- [x] Mobile users surface the mobile device requirement; an unsure mobile count surfaces nothing -- [x] Some sites on Iti keep the facility-server requirement and add the Iti one; every site on Iti drops the facility server and keeps only Iti -- [x] Every requirement profile in the ruleset names a class and has at least one spec row (flat or per-band) - -## Size scaling - -- [x] A client-hosted server's processor/memory/storage scale with the derived size band (Tiny vs Large give different rows) -- [x] Size-varying rows lead; network and OS rows follow -- [x] The smallest band carries an advisory to host with BES or use an Iti rather than buy a server; larger bands do not -- [x] The Iti profile states its one-model hardware spec (4c/8GB/500GB), not size-varying -- [x] A draft not yet sized (bands unanswered) falls back to the lightest band's rows - -## Artifact rendering - -- [x] The finalised web view shows a "Compute requirements" section below the consequence groups, one block per present class, each with its spec rows -- [ ] Each block shows the who-provisions summary and, where authored, the note -- [x] The section is absent from no deployment (user devices always present, so it never renders empty) -- [ ] The PDF export includes the compute requirements with every block expanded - -## Ruleset integrity - -- [x] The bundled ruleset with requirements parses, validates (unique requirement ids), and hashes deterministically -- [x] An older artifact bound to a ruleset without a `requirements` block still loads (serde default) diff --git a/crates/pollen-server/src/ruleset/engine.rs b/crates/pollen-server/src/ruleset/engine.rs index 2403445..98656ef 100644 --- a/crates/pollen-server/src/ruleset/engine.rs +++ b/crates/pollen-server/src/ruleset/engine.rs @@ -9,7 +9,9 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use super::answers::{Answer, Answers}; -use super::model::{Consequence, DerivationKind, QuestionKind, Ruleset, Severity, Spec, SpecRow}; +use super::model::{ + Consequence, DerivationKind, NoteRow, QuestionKind, Ruleset, Severity, Spec, SpecRow, +}; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct Evaluation { @@ -59,7 +61,7 @@ pub struct TriggeredRequirement { pub class: String, pub summary: Option, pub specs: Vec, - pub note: Option, + pub notes: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -159,17 +161,24 @@ pub fn evaluate(ruleset: &Ruleset, answers: &Answers) -> Evaluation { specs.extend(present(&ss.specs)); } specs.extend(present(&r.specs)); - // A band-specific note augments the profile's own. - let note = match (r.note.clone(), by_size.and_then(|ss| ss.note.clone())) { - (Some(base), Some(band)) => Some(format!("{base} {band}")), - (base, band) => base.or(band), - }; + // The band-specific hint leads, as the size-varying rows do; the + // profile's own hints follow, minus any whose condition fails. + let mut notes: Vec = Vec::new(); + if let Some(band) = by_size.and_then(|ss| ss.note.clone()) { + notes.push(band); + } + notes.extend( + r.notes + .iter() + .filter(|n: &&NoteRow| n.when.eval(answers)) + .map(|n| n.text.clone()), + ); TriggeredRequirement { id: r.id.clone(), class: r.class.clone(), summary: r.summary.clone(), specs, - note, + notes, } }) .collect(); diff --git a/crates/pollen-server/src/ruleset/mod.rs b/crates/pollen-server/src/ruleset/mod.rs index 9351fc8..4ff287d 100644 --- a/crates/pollen-server/src/ruleset/mod.rs +++ b/crates/pollen-server/src/ruleset/mod.rs @@ -19,8 +19,8 @@ pub use engine::{ }; pub use migrate::{Migration, migrate}; pub use model::{ - Audience, Consequence, ConsequenceType, Cost, Derivation, DerivationKind, Guidance, Opt, - Question, QuestionKind, Requirement, Rule, Ruleset, Section, Severity, SizeSpecs, Spec, + Audience, Consequence, ConsequenceType, Cost, Derivation, DerivationKind, Guidance, NoteRow, + Opt, Question, QuestionKind, Requirement, Rule, Ruleset, Section, Severity, SizeSpecs, Spec, SpecRow, Status, }; pub use resolver::{RULESET_PATH, ResolvedRuleset, RulesetResolver}; diff --git a/crates/pollen-server/src/ruleset/model.rs b/crates/pollen-server/src/ruleset/model.rs index 289aa73..a5b00a8 100644 --- a/crates/pollen-server/src/ruleset/model.rs +++ b/crates/pollen-server/src/ruleset/model.rs @@ -251,9 +251,20 @@ pub struct Requirement { /// for a class that is the same at every size (user devices, mobile, Iti). #[serde(default)] pub by_size: Vec, - /// An optional caveat shown beneath the rows. + /// Hints shown beneath the rows. A hint may be gated on the answers, so + /// guidance that only applies to one way of provisioning (virtual machines, + /// say) sits with the figures it qualifies rather than in a separate list. #[serde(default)] - pub note: Option, + pub notes: Vec, +} + +/// One hint beneath a requirement's rows, optionally gated on the answers. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NoteRow { + pub text: String, + /// Included only when this holds. Defaults to always. + #[serde(default = "Condition::always")] + pub when: Condition, } /// The size-varying spec rows for one size band of a [`Requirement`]. diff --git a/crates/pollen-server/tests/ruleset.rs b/crates/pollen-server/tests/ruleset.rs index 7d04a10..eb26bcf 100644 --- a/crates/pollen-server/tests/ruleset.rs +++ b/crates/pollen-server/tests/ruleset.rs @@ -105,7 +105,6 @@ fn demo_config_is_blocking() { "int-capacity", "region-other", "plat-windows", - "prov-baremetal", "iti-note", "dns-client", "remote-other", @@ -117,9 +116,8 @@ fn demo_config_is_blocking() { "expected {expected} to fire; got {ids:?}" ); } - // Not fired: the client hosts integrations; the servers aren't virtualised. + // Not fired: the client hosts the integrations themselves. assert!(!ids.contains(&"int-hosted")); - assert!(!ids.contains(&"prov-virtualised")); } #[test] @@ -984,9 +982,8 @@ fn the_smallest_band_advises_against_buying_a_server() { .iter() .find(|r| r.id == "req-central") .unwrap() - .note - .clone() - .unwrap_or_default() + .notes + .join(" ") }; let tiny = evaluate( diff --git a/ruleset.ron b/ruleset.ron index 4100991..5746300 100644 --- a/ruleset.ron +++ b/ruleset.ron @@ -563,35 +563,6 @@ ), // ── Client-hosted server provisioning ─────────────────────────────── - ( - id: "prov-baremetal", - source: "topology", - when: Any([ - Equals("onprem_form", "baremetal"), - Equals("onprem_form", "mixed"), - ]), - consequence: ( - status: Requirement, - audience: Client, - types: [Operational], - title: "Provision the physical servers generously up front", - detail: "Disk and resources are hard to change on bare metal, so provision ahead: at least 500 GB SSD, 4 cores (prefer 8), and 16 GB RAM (prefer 32). Multiple disks and multiple TB are NOT usually required, though you may want to do RAID for redundancy.", - ), - ), - ( - id: "prov-virtualised", - source: "topology", - when: Any([ - Equals("onprem_form", "virtualised"), - Equals("onprem_form", "mixed"), - ]), - consequence: ( - status: Advisory, - audience: Client, - title: "Start the virtual machines small, and back them up", - detail: "Resources can scale, so start around 50 GB / 4 cores / 16 GB and grow. VM-level backups are recommended; telling BES is not required but helps disaster planning.", - ), - ), ( id: "iti-note", source: "topology", @@ -1190,6 +1161,22 @@ (label: "Operating system", value: "Linux", when: Equals("platform", "linux")), (label: "Operating system", value: "Windows Server", when: Equals("platform", "windows")), ], + notes: [ + ( + text: "Aim for these figures; a virtual machine may sit slightly under if its host has room to grow. VM-level backups are recommended, and worth telling BES about for disaster planning.", + when: Any([ + Equals("onprem_form", "virtualised"), + Equals("onprem_form", "mixed"), + ]), + ), + ( + text: "These figures are a minimum; buying more is wise to allow for growth. Multiple disks or terabytes are not usually needed, though RAID helps redundancy.", + when: Any([ + Equals("onprem_form", "baremetal"), + Equals("onprem_form", "mixed"), + ]), + ), + ], // Sized to the deployment. The figures track the recommended on-prem // tower tiers; the make is a suggestion, not a requirement. by_size: [ @@ -1246,7 +1233,23 @@ (label: "Operating system", value: "Linux", when: Equals("platform", "linux")), (label: "Operating system", value: "Windows Server", when: Equals("platform", "windows")), ], - note: Some("Keeps working within the facility when the link to Central is down."), + notes: [ + (text: "Keeps working within the facility when the link to Central is down."), + ( + text: "Aim for these figures; a virtual machine may sit slightly under if its host has room to grow. VM-level backups are recommended, and worth telling BES about for disaster planning.", + when: Any([ + Equals("onprem_form", "virtualised"), + Equals("onprem_form", "mixed"), + ]), + ), + ( + text: "These figures are a minimum; buying more is wise to allow for growth. Multiple disks or terabytes are not usually needed, though RAID helps redundancy.", + when: Any([ + Equals("onprem_form", "baremetal"), + Equals("onprem_form", "mixed"), + ]), + ), + ], by_size: [ ( size: "Tiny", diff --git a/web/openapi.json b/web/openapi.json index e0ee350..44b3fc9 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -795,7 +795,8 @@ "required": [ "id", "class", - "specs" + "specs", + "notes" ], "properties": { "class": { @@ -804,11 +805,11 @@ "id": { "type": "string" }, - "note": { - "type": [ - "string", - "null" - ] + "notes": { + "type": "array", + "items": { + "type": "string" + } }, "specs": { "type": "array", diff --git a/web/src/api-types.ts b/web/src/api-types.ts index 83153fb..1f110fc 100644 --- a/web/src/api-types.ts +++ b/web/src/api-types.ts @@ -352,7 +352,7 @@ export interface components { TriggeredRequirement: { class: string; id: string; - note?: string | null; + notes: string[]; specs: components["schemas"]["Spec"][]; summary?: string | null; }; diff --git a/web/src/app.css b/web/src/app.css index 22dac58..ebc8ab5 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -579,12 +579,18 @@ body { font-weight: 500; line-height: 1.45; } -/* Advisory, so it takes the sky informational wash rather than a severity hue. */ -.req-note { - margin: 0; +/* Advisory, so it takes the sky informational wash rather than a severity hue. + Several hints share one wash so the card ends in a single block. */ +.req-notes { + display: flex; + flex-direction: column; + gap: 7px; padding: 10px 16px; background: var(--sky-tint); border-top: 1px solid #bfe4f8; +} +.req-note { + margin: 0; font-size: 12px; color: var(--navy-deep); line-height: 1.45; diff --git a/web/src/components/Artifact.tsx b/web/src/components/Artifact.tsx index 8dae2eb..514277f 100644 --- a/web/src/components/Artifact.tsx +++ b/web/src/components/Artifact.tsx @@ -204,10 +204,14 @@ export default function Artifact({ view }: { view: AppView }) { ))} - {r.note && ( -

- -

+ {r.notes.length > 0 && ( +
+ {r.notes.map((n) => ( +

+ +

+ ))} +
)} ))}