diff --git a/.workhorse/plans/g1/plan.md b/.workhorse/plans/g1/plan.md new file mode 100644 index 0000000..d6eb10b --- /dev/null +++ b/.workhorse/plans/g1/plan.md @@ -0,0 +1,71 @@ +# 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 e347542..9792a1a 100644 --- a/.workhorse/specs/wizard/onboarding.md +++ b/.workhorse/specs/wizard/onboarding.md @@ -101,6 +101,9 @@ Authored prose (consequence detail, question help, option notes, guidance) may carry limited inline markup: links, which open in a new tab, and light emphasis, so it can point to further documentation. +The ruleset also carries a compute requirement profile for each class of server or device, each gated by the same trigger conditions. +The engine surfaces the profiles whose class is present in the deployment, which the artifact renders as the [Compute requirements](#compute-requirements). + ### Visibility and forward guidance The engine shows a question only when its precondition holds, and hides it otherwise; a precondition is a presence-of-class flag or a cross-field condition. @@ -226,11 +229,13 @@ This comparison is the "what changed" summary the user sees on update: newly-req ### Finalised web view The canonical artifact is a live page. -It presents every consequence in full, in four groups: warnings, being what the client is opting into by leaving the standard path, then what the client's IT team has to do, then what the BES technical team sets up, then what the BES pricing and partnerships team has to price or commit to. +Warnings lead it: what the client is opting into by leaving the standard path is met before anything else. +The compute requirements follow (see [Compute requirements](#compute-requirements)), being the answer the reader came for. +Beneath those, under a **Next steps** heading that frames them as who does what, it presents the remaining consequences in full, in three groups: what the client's IT team has to do, then what the BES technical team sets up, then what the BES pricing and partnerships team has to price or commit to. An item states the action it asks for, so a group of required actions reads as a list of work rather than a list of observations. A choice off the standard path that also asks something of a team produces two items: the work, which sits with that team's actions, and the acknowledgement of what the choice costs, which sits with what is being opted into. Nothing off the standard path appears in a group of actions, because an acknowledgement is not something anyone does. -Warnings come first and arrive collapsed, carrying the same off-default colour they carry everywhere else: they are context for the work below rather than the work itself, and a conflict that stops the configuration working is raised by the viability callout regardless. +Warnings arrive collapsed, carrying the same off-default colour they carry everywhere else: they are context rather than the work itself, and a conflict that stops the configuration working is raised by the viability callout regardless. Each group can be collapsed, so a reader can skip past the groups addressed to someone else. A section can be linked to directly. @@ -246,6 +251,25 @@ Its header surfaces non-identifying recognition facts so one artifact is disting A fact the engine assumed reads the same as one the reader chose, because it is the answer until they change it. Settings that exist to be acted on, such as the hosting region, appear with the work they imply rather than in the header. + +### Compute requirements + +The compute requirements answer a single question for the reader: what would they have to provide themselves to run this deployment. +So the artifact states the concrete requirements for each class of server and device the client provisions, and only those. +Each class is presented as its own block: the class name, a short line on who provisions it, a set of labelled spec rows leading with processor, memory and storage and then network and operating system or software, and an optional note. + +Which classes appear is driven by the answers, so a reader sees only what their deployment needs someone to buy or provide. +A class BES provisions itself, such as a central or facility server hosted in BES cloud, carries no block, because the client provides nothing for it. +So the central server appears only when the client hosts it; a facility server appears when a client-hosted facility runs its own server rather than a mini-server; the Tamanu Iti mini-server, which the client buys from BES, appears when any site uses one; the user devices staff work at always appear; and mobile devices appear when the deployment has mobile users. + +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. +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. +Devices that do not scale, such as workstations and phones, state one recommended specification. +Indicative pricing is out of scope here and is settled by the pricing and partnerships team, who already receive an item to price the hosting. + ### PDF export The artifact can be exported to PDF, sectioned by audience, as a static snapshot derived from the same data. @@ -255,12 +279,13 @@ Its sections, in order: 1. **Viability verdict**: any blocking conflicts, at the top. 2. **Open questions**: on an interim artifact, what is still to be settled, before any of the detail that rests on it. 3. **Warnings**: what the client is opting into by leaving the standard path. -4. **Client IT team, required actions**: ports, outbound endpoints, DNS, remote access, time synchronisation. -5. **BES technical team, setup decisions**: staging, topology, platform, server specifications, backup and retention, region. -6. **BES pricing and partnerships**: what has to be priced, and what BES can commit to supporting. -7. **Referrals**: items escalated to a separate conversation. -8. **Assumptions**: the answers the engine filled in where the reader left a question blank. -9. **Full decision record**: everything captured. +4. **Compute requirements**: the specs for each server and device class the deployment uses. +5. **Client IT team, required actions**: ports, outbound endpoints, DNS, remote access, time synchronisation. +6. **BES technical team, setup decisions**: staging, topology, platform, backup and retention, region. +7. **BES pricing and partnerships**: what has to be priced, and what BES can commit to supporting. +8. **Referrals**: items escalated to a separate conversation. +9. **Assumptions**: the answers the engine filled in where the reader left a question blank. +10. **Full decision record**: everything captured. ## Data and confidentiality diff --git a/.workhorse/test-cases/g1/overview.md b/.workhorse/test-cases/g1/overview.md new file mode 100644 index 0000000..377f447 --- /dev/null +++ b/.workhorse/test-cases/g1/overview.md @@ -0,0 +1,34 @@ +# 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 c322a70..2403445 100644 --- a/crates/pollen-server/src/ruleset/engine.rs +++ b/crates/pollen-server/src/ruleset/engine.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use super::answers::{Answer, Answers}; -use super::model::{Consequence, DerivationKind, QuestionKind, Ruleset, Severity}; +use super::model::{Consequence, DerivationKind, QuestionKind, Ruleset, Severity, Spec, SpecRow}; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct Evaluation { @@ -20,6 +20,9 @@ pub struct Evaluation { pub visible_questions: Vec, /// Every triggered consequence, in ruleset order. pub consequences: Vec, + /// The compute requirements for the classes present in the deployment, in + /// ruleset order (spec WIZ, Compute requirements). + pub requirements: Vec, /// Guidance whose condition currently holds. pub guidance: Vec, /// Visible questions left unanswered whose blessed-path default the engine @@ -48,6 +51,17 @@ pub struct TriggeredConsequence { pub consequence: Consequence, } +/// A compute requirement whose class is present in the deployment. Carries the +/// profile's content (the `when` condition that selected it is not on the wire). +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct TriggeredRequirement { + pub id: String, + pub class: String, + pub summary: Option, + pub specs: Vec, + pub note: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct TriggeredGuidance { pub at: String, @@ -111,6 +125,55 @@ pub fn evaluate(ruleset: &Ruleset, answers: &Answers) -> Evaluation { }) .collect(); + // Requirements are gated on the same defaulted answers as consequences, so an + // assumed hosting choice surfaces the classes it implies. A sized class picks + // its rows from the derived size band, falling back to the lightest band when + // the deployment isn't sized yet (a draft before the bands are answered). + let size = derived.get("size").map(String::as_str); + let requirements: Vec = ruleset + .requirements + .iter() + .filter(|r| r.when.eval(answers)) + .map(|r| { + let by_size = (!r.by_size.is_empty()) + .then(|| { + size.and_then(|s| r.by_size.iter().find(|ss| ss.size == s)) + .or_else(|| r.by_size.first()) + }) + .flatten(); + // Size-varying rows (processor, memory, storage) lead; the invariant + // rows (network, operating system) follow. A row gated on the answers + // is dropped unless it holds, so a requirement states the choice the + // reader made rather than every option. + let present = |rows: &[SpecRow]| -> Vec { + rows.iter() + .filter(|row| row.when.eval(answers)) + .map(|row| Spec { + label: row.label.clone(), + value: row.value.clone(), + }) + .collect() + }; + let mut specs = Vec::new(); + if let Some(ss) = by_size { + 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), + }; + TriggeredRequirement { + id: r.id.clone(), + class: r.class.clone(), + summary: r.summary.clone(), + specs, + note, + } + }) + .collect(); + let guidance: Vec = ruleset .guidance .iter() @@ -139,6 +202,7 @@ pub fn evaluate(ruleset: &Ruleset, answers: &Answers) -> Evaluation { derived, visible_questions, consequences, + requirements, guidance, assumed, open_items, diff --git a/crates/pollen-server/src/ruleset/mod.rs b/crates/pollen-server/src/ruleset/mod.rs index 6590b1b..9351fc8 100644 --- a/crates/pollen-server/src/ruleset/mod.rs +++ b/crates/pollen-server/src/ruleset/mod.rs @@ -13,11 +13,15 @@ pub mod source; pub use answers::{Answer, Answers}; pub use condition::Condition; -pub use engine::{Assumed, Evaluation, TriggeredConsequence, TriggeredGuidance, Verdict, evaluate}; +pub use engine::{ + Assumed, Evaluation, TriggeredConsequence, TriggeredGuidance, TriggeredRequirement, Verdict, + evaluate, +}; pub use migrate::{Migration, migrate}; pub use model::{ Audience, Consequence, ConsequenceType, Cost, Derivation, DerivationKind, Guidance, Opt, - Question, QuestionKind, Rule, Ruleset, Section, Severity, Status, + Question, QuestionKind, Requirement, Rule, Ruleset, Section, Severity, SizeSpecs, Spec, + SpecRow, Status, }; pub use resolver::{RULESET_PATH, ResolvedRuleset, RulesetResolver}; pub use source::{GitHubSource, RefSource}; diff --git a/crates/pollen-server/src/ruleset/model.rs b/crates/pollen-server/src/ruleset/model.rs index 858f150..289aa73 100644 --- a/crates/pollen-server/src/ruleset/model.rs +++ b/crates/pollen-server/src/ruleset/model.rs @@ -20,6 +20,10 @@ pub struct Ruleset { pub rules: Vec, #[serde(default)] pub guidance: Vec, + /// Compute requirement profiles, surfaced in the artifact for each server or + /// device class present in the deployment (spec WIZ, Compute requirements). + #[serde(default)] + pub requirements: Vec, } impl Ruleset { @@ -61,6 +65,38 @@ impl Ruleset { } } + let mut requirement_ids = HashSet::new(); + for r in &self.requirements { + if !requirement_ids.insert(r.id.as_str()) { + return Err(AppError::custom(format!( + "duplicate requirement id: {}", + r.id + ))); + } + // A profile with no rows at any size would render an empty class. + if r.specs.is_empty() && r.by_size.is_empty() { + return Err(AppError::custom(format!( + "requirement {} has no spec rows", + r.id + ))); + } + let mut sizes = HashSet::new(); + for ss in &r.by_size { + if !sizes.insert(ss.size.as_str()) { + return Err(AppError::custom(format!( + "requirement {} repeats size band {}", + r.id, ss.size + ))); + } + if ss.specs.is_empty() { + return Err(AppError::custom(format!( + "requirement {} size band {} has no spec rows", + r.id, ss.size + ))); + } + } + } + Ok(()) } } @@ -187,6 +223,77 @@ pub struct Cost { pub ballpark: Option, } +/// A compute requirement profile for one class of server or device. Surfaced in +/// the artifact when its `when` condition holds, i.e. when that class is present +/// in the deployment (spec WIZ, Compute requirements). +/// +/// A profile carries the size-invariant rows in `specs` (network, operating +/// system) and, where the class is sized to the deployment, a per-band set of +/// rows in `by_size` (processor, memory, storage). The engine resolves `by_size` +/// against the derived size band and presents the matching rows ahead of the +/// invariant ones. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Requirement { + /// Permanent identifier. Never reused or repurposed (spec WIZ, stable-id). + pub id: String, + /// Surfaced only when this holds (e.g. the class is present in the mix). + pub when: Condition, + /// The server or device class this profile describes, e.g. "Central server". + pub class: String, + /// A short line on who provisions this class and when it appears. + #[serde(default)] + pub summary: Option, + /// Size-invariant spec rows (e.g. network, operating system). Shown after the + /// size-varying rows. + #[serde(default)] + pub specs: Vec, + /// Spec rows that scale with the deployment's size band, keyed by band. Empty + /// 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. + #[serde(default)] + pub note: Option, +} + +/// The size-varying spec rows for one size band of a [`Requirement`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SizeSpecs { + /// The size band label these rows apply to (matches a `size` derivation + /// label, e.g. "Small"). + pub size: String, + /// The size-varying rows (processor, memory, storage). + pub specs: Vec, + /// An optional note specific to this band (e.g. the smallest band advising a + /// hosted or mini-server option over buying a server). + #[serde(default)] + pub note: Option, +} + +/// One authored row of a compute requirement: a labelled figure such as +/// `("Memory", "16 GB")`. +/// +/// A row may be gated on the answers, so a requirement can state the choice the +/// reader actually made rather than listing every option. Rows sharing a label +/// (e.g. one operating system row per platform) are authored mutually +/// exclusive, so exactly one survives. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpecRow { + pub label: String, + pub value: String, + /// Included only when this holds. Defaults to always. + #[serde(default = "Condition::always")] + pub when: Condition, +} + +/// One row of a compute requirement as presented: the engine has already +/// resolved the size band and dropped the rows whose condition does not hold. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Spec { + pub label: String, + pub value: String, +} + /// The viability axis (spec WIZ, Severity). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, utoipa::ToSchema)] pub enum Severity { diff --git a/crates/pollen-server/tests/ruleset.rs b/crates/pollen-server/tests/ruleset.rs index f39300c..7d04a10 100644 --- a/crates/pollen-server/tests/ruleset.rs +++ b/crates/pollen-server/tests/ruleset.rs @@ -24,6 +24,10 @@ fn fired_ids(eval: &pollen_server::ruleset::Evaluation) -> Vec<&str> { eval.consequences.iter().map(|c| c.id.as_str()).collect() } +fn requirement_ids(eval: &pollen_server::ruleset::Evaluation) -> Vec<&str> { + eval.requirements.iter().map(|r| r.id.as_str()).collect() +} + /// The three sizing bands, so a test can focus on what it's actually asserting /// without leaving the required questions unanswered. fn sized() -> serde_json::Value { @@ -804,6 +808,267 @@ fn pricing_and_sla_drivers_reach_the_pricing_group() { } } +// ── Compute requirements ───────────────────────────────────────────────────── + +#[test] +fn compute_requirements_track_the_classes_present() { + // The default path sizes small, all-client facilities with BES cloud Central. + // Central is BES-hosted so it carries no client requirement; the facilities + // and the workstations do; there are no mobile devices. + let eval = evaluate(&v1(), &answers(sized())); + let ids = requirement_ids(&eval); + assert!(ids.contains(&"req-facility"), "got {ids:?}"); + assert!(ids.contains(&"req-workstation"), "got {ids:?}"); + assert!( + !ids.contains(&"req-central"), + "BES hosts Central by default" + ); + assert!(!ids.contains(&"req-mobile"), "no mobile users by default"); + assert!(!ids.contains(&"req-iti")); +} + +#[test] +fn an_all_cloud_deployment_only_needs_workstations() { + // Everything BES-hosted, no mobile: the client provisions nothing but the + // devices staff use to reach Tamanu. + let eval = evaluate( + &v1(), + &answers(json!({ + "catchment": "c0", + "facilities": "f0", + "mobile": "m0", + "central": "bescloud", + "hosting_where": "allbes", + })), + ); + assert_eq!(requirement_ids(&eval), vec!["req-workstation"]); +} + +#[test] +fn a_client_hosted_central_carries_its_own_requirement() { + let eval = evaluate(&v1(), &with(sized(), json!({ "central": "clienthosted" }))); + assert!(requirement_ids(&eval).contains(&"req-central")); +} + +#[test] +fn mobile_users_bring_a_mobile_device_requirement() { + let with_mobile = evaluate(&v1(), &with(sized(), json!({ "mobile": "m2" }))); + assert!(requirement_ids(&with_mobile).contains(&"req-mobile")); + + // An unsure mobile count asserts nothing, so no device requirement fires. + let unsure = evaluate(&v1(), &with(sized(), json!({ "mobile": "m_unsure" }))); + assert!(!requirement_ids(&unsure).contains(&"req-mobile")); +} + +#[test] +fn iti_replaces_the_facility_server_requirement_when_every_site_runs_one() { + // Some sites on Iti keeps the facility-server requirement for the rest. + let some = evaluate( + &v1(), + &with( + sized(), + json!({ "hosting_where": "allclient", "iti_use": "some" }), + ), + ); + let some_ids = requirement_ids(&some); + assert!(some_ids.contains(&"req-facility")); + assert!(some_ids.contains(&"req-iti")); + + // Every site on Iti: there is no client-run facility server left to spec. + let all = evaluate( + &v1(), + &with( + sized(), + json!({ "hosting_where": "allclient", "iti_use": "all" }), + ), + ); + let all_ids = requirement_ids(&all); + assert!(all_ids.contains(&"req-iti")); + assert!(!all_ids.contains(&"req-facility")); +} + +#[test] +fn a_ruleset_stored_without_requirements_still_loads() { + // A finalised artifact bound before compute requirements existed has no + // `requirements` field in its stored JSON. The append-only model must still + // read it (spec WIZ, the engine's model is append-only). + let stored = json!({ + "questions": [ + { "id": "catchment", "kind": "Band", "label": "?", "options": [ { "id": "c0", "label": "?" } ] }, + ], + "rules": [], + }); + let ruleset: Ruleset = serde_json::from_value(stored).expect("loads without requirements"); + assert!(ruleset.requirements.is_empty()); + let eval = evaluate(&ruleset, &answers(json!({ "catchment": "c0" }))); + assert!(eval.requirements.is_empty()); +} + +#[test] +fn every_requirement_names_a_class_and_at_least_one_spec_row() { + // A profile with no rows at any size would render an empty class heading. + for r in &v1().requirements { + assert!(!r.class.is_empty(), "requirement {} has no class", r.id); + assert!( + !r.specs.is_empty() || !r.by_size.is_empty(), + "requirement {} has no spec rows", + r.id + ); + } +} + +#[test] +fn a_sized_server_scales_its_specs_with_the_size_band() { + // The client-hosted server profiles pick their processor, memory and storage + // from the derived size band, and lead with those rows. + let spec = |eval: &pollen_server::ruleset::Evaluation, id: &str, label: &str| -> String { + eval.requirements + .iter() + .find(|r| r.id == id) + .unwrap_or_else(|| panic!("{id} present")) + .specs + .iter() + .find(|s| s.label == label) + .unwrap_or_else(|| panic!("{id} has a {label} row")) + .value + .clone() + }; + + // Tiny (the lightest band) versus Large, client-hosted throughout. + let tiny = evaluate( + &v1(), + &answers(json!({ "catchment": "c0", "facilities": "f0", "central": "clienthosted" })), + ); + assert_eq!( + spec(&tiny, "req-central", "Processor"), + "2 cores, x86_64 or ARM64" + ); + assert_eq!(spec(&tiny, "req-central", "Storage"), "480 GB SSD"); + + let large = evaluate( + &v1(), + &answers(json!({ "catchment": "c3", "facilities": "f0", "central": "clienthosted" })), + ); + assert_eq!( + spec(&large, "req-central", "Processor"), + "8 cores, x86_64 or ARM64" + ); + assert_eq!(spec(&large, "req-central", "Memory"), "32 GB"); + assert_eq!(spec(&large, "req-central", "Storage"), "2 TB SSD"); + + // The size-varying rows lead; the invariant network/OS rows follow. + let central = large + .requirements + .iter() + .find(|r| r.id == "req-central") + .unwrap(); + let labels: Vec<&str> = central.specs.iter().map(|s| s.label.as_str()).collect(); + assert_eq!( + labels, + vec![ + "Processor", + "Memory", + "Storage", + "Network", + "Operating system" + ] + ); +} + +#[test] +fn the_smallest_band_advises_against_buying_a_server() { + // A Tiny deployment that still chooses to self-host is steered toward BES + // hosting or an Iti rather than dedicated hardware. + let note = |eval: &pollen_server::ruleset::Evaluation| -> String { + eval.requirements + .iter() + .find(|r| r.id == "req-central") + .unwrap() + .note + .clone() + .unwrap_or_default() + }; + + let tiny = evaluate( + &v1(), + &answers(json!({ "catchment": "c0", "facilities": "f0", "central": "clienthosted" })), + ); + assert!( + note(&tiny).contains("cost-effective"), + "tiny central should carry the hosting advisory; got {:?}", + note(&tiny) + ); + + // A larger band carries no such advisory. + let large = evaluate( + &v1(), + &answers(json!({ "catchment": "c3", "facilities": "f0", "central": "clienthosted" })), + ); + assert!(!note(&large).contains("cost-effective")); +} + +#[test] +fn the_operating_system_row_states_the_platform_chosen() { + // The requirement reports the reader's own selection rather than listing the + // options, so exactly one operating system row survives. + let os = |platform: &str| -> Vec { + let eval = evaluate( + &v1(), + &with( + sized(), + json!({ "central": "clienthosted", "platform": platform }), + ), + ); + eval.requirements + .iter() + .find(|r| r.id == "req-central") + .expect("central present") + .specs + .iter() + .filter(|s| s.label == "Operating system") + .map(|s| s.value.clone()) + .collect() + }; + + assert_eq!(os("linux"), vec!["Linux"]); + assert_eq!(os("windows"), vec!["Windows Server"]); +} + +#[test] +fn mobile_devices_need_android_13() { + let eval = evaluate(&v1(), &with(sized(), json!({ "mobile": "m2" }))); + let mobile = eval + .requirements + .iter() + .find(|r| r.id == "req-mobile") + .expect("mobile present"); + let os = mobile + .specs + .iter() + .find(|s| s.label == "Operating system") + .expect("has an OS row"); + assert_eq!(os.value, "Android 13 or newer"); +} + +#[test] +fn an_unsized_draft_falls_back_to_the_lightest_band() { + // A draft where the bands aren't answered yet has no derived size, so a sized + // server shows the lightest band's rows rather than none. + let eval = evaluate(&v1(), &answers(json!({ "central": "clienthosted" }))); + assert!(!eval.derived.contains_key("size")); + let central = eval + .requirements + .iter() + .find(|r| r.id == "req-central") + .expect("central still shown"); + let processor = central + .specs + .iter() + .find(|s| s.label == "Processor") + .expect("has a processor row"); + assert_eq!(processor.value, "2 cores, x86_64 or ARM64"); +} + #[test] fn a_standard_plan_still_has_something_to_price() { // Even a plan entirely on the blessed path costs something to host, so the diff --git a/ruleset.ron b/ruleset.ron index 3329989..4100991 100644 --- a/ruleset.ron +++ b/ruleset.ron @@ -1169,4 +1169,164 @@ ), ), ], + + // Compute requirements (spec WIZ). One profile per server or device class; + // each surfaces when its class is present in the deployment. The figures are + // the recommended base-level specs from the Tamanu compute-resource + // reference; larger deployments are advised further by BES, so nothing here + // scales by size band. Only classes someone must provision appear: a + // BES-cloud server is provisioned by BES and carries no client-facing + // requirement, so the server profiles gate on client hosting. + requirements: [ + ( + id: "req-central", + // Only when the client hosts Central; BES provisions the cloud one. + when: Equals("central", "clienthosted"), + class: "Central server", + summary: Some("Provisioned by the client's IT team. Ties the facilities together."), + // Size-invariant rows; the processor, memory and storage scale below. + specs: [ + (label: "Network", value: "50 down / 20 up Mbps. Inbound TCP 80 and 443, and UDP 443. A public DNS name and TLS certificate."), + (label: "Operating system", value: "Linux", when: Equals("platform", "linux")), + (label: "Operating system", value: "Windows Server", when: Equals("platform", "windows")), + ], + // Sized to the deployment. The figures track the recommended on-prem + // tower tiers; the make is a suggestion, not a requirement. + by_size: [ + ( + size: "Tiny", + specs: [ + (label: "Processor", value: "2 cores, x86_64 or ARM64"), + (label: "Memory", value: "16 GB"), + (label: "Storage", value: "480 GB SSD"), + ], + note: Some("For a deployment this small, letting BES host the central server in its cloud is usually more cost-effective than buying and maintaining hardware."), + ), + ( + size: "Small", + specs: [ + (label: "Processor", value: "2 cores, x86_64 or ARM64"), + (label: "Memory", value: "16 GB"), + (label: "Storage", value: "480 GB SSD"), + ], + ), + ( + size: "Medium", + specs: [ + (label: "Processor", value: "4 cores, x86_64 or ARM64"), + (label: "Memory", value: "16 GB"), + (label: "Storage", value: "960 GB SSD"), + ], + ), + ( + size: "Large", + specs: [ + (label: "Processor", value: "8 cores, x86_64 or ARM64"), + (label: "Memory", value: "32 GB"), + (label: "Storage", value: "2 TB SSD"), + ], + ), + ], + ), + ( + id: "req-facility", + // Any client-hosted facility that runs its own server, i.e. not the + // sites handed to a Tamanu Iti mini-server. + when: All([ + Any([ + Equals("hosting_where", "allclient"), + Equals("hosting_where", "mix"), + ]), + Not(Equals("iti_use", "all")), + ]), + class: "Facility server", + summary: Some("Provisioned by the client's IT team, one per facility that runs its own server."), + specs: [ + (label: "Network", value: "To Central: 20 down / 5 up Mbps, under 300ms latency. Inbound TCP 80 and 443, and UDP 443, internally."), + (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."), + by_size: [ + ( + size: "Tiny", + specs: [ + (label: "Processor", value: "2 cores, x86_64 or ARM64"), + (label: "Memory", value: "16 GB"), + (label: "Storage", value: "480 GB SSD"), + ], + note: Some("For a single small facility, a Tamanu Iti mini-server or BES-cloud hosting is usually more cost-effective than a dedicated server."), + ), + ( + size: "Small", + specs: [ + (label: "Processor", value: "2 cores, x86_64 or ARM64"), + (label: "Memory", value: "16 GB"), + (label: "Storage", value: "480 GB SSD"), + ], + ), + ( + size: "Medium", + specs: [ + (label: "Processor", value: "4 cores, x86_64 or ARM64"), + (label: "Memory", value: "16 GB"), + (label: "Storage", value: "960 GB SSD"), + ], + ), + ( + size: "Large", + specs: [ + (label: "Processor", value: "8 cores, x86_64 or ARM64"), + (label: "Memory", value: "32 GB"), + (label: "Storage", value: "2 TB SSD"), + ], + ), + ], + ), + ( + id: "req-iti", + when: Any([ + Equals("iti_use", "some"), + Equals("iti_use", "all"), + ]), + class: "Tamanu Iti mini-server", + summary: Some("A mini-server bought from BES, plugged into the facility network. Runs ARM64 Linux with its own WiFi and battery backup. One model, whatever the size."), + specs: [ + (label: "Processor", value: "4 cores, ARM64"), + (label: "Memory", value: "8 GB"), + (label: "Storage", value: "500 GB SSD"), + (label: "Network", value: "To Central: 20 down / 5 up Mbps, under 300ms latency. Reachable by facility users on ports 80 and 443."), + ], + ), + ( + id: "req-workstation", + // The client always needs devices to reach Tamanu. + when: Always, + class: "User devices", + summary: Some("Any desktop or laptop staff use to reach Tamanu. Provided by the client."), + specs: [ + (label: "Processor", value: "2 cores"), + (label: "Memory", value: "4 GB"), + (label: "Software", value: "Google Chrome or Chromium, desktop version, one of the latest two stable releases. Mobile browsers are not supported."), + (label: "Network", value: "To the Facility server: 10 down / 2 up Mbps, under 400ms latency. Access on ports 80 and 443."), + ], + ), + ( + id: "req-mobile", + when: All([ + Answered("mobile"), + Not(Equals("mobile", "m0")), + Not(Equals("mobile", "m_unsure")), + ]), + class: "Mobile devices", + summary: Some("Phones or tablets for community and field health workers. Provided by the client."), + specs: [ + (label: "Operating system", value: "Android 13 or newer"), + (label: "Processor", value: "4 cores, ARM64"), + (label: "Memory", value: "2 GB"), + (label: "Storage", value: "8 GB"), + (label: "Network", value: "100 down / 28 up Kbps, under 500ms latency. The first sync is best done on a faster network, such as wifi."), + ], + ), + ], ) diff --git a/scripts/dev-down.sh b/scripts/dev-down.sh new file mode 100755 index 0000000..3ffb656 --- /dev/null +++ b/scripts/dev-down.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Stop the detached dev stack started by dev-up.sh (leaves the cluster's data). +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +RUN="$ROOT/.dev-stack" +PGDIR="$HOME/.cache/pollen-dev-pg" + +for name in api web; do + pid_file="$RUN/$name.pid" + if [ -f "$pid_file" ]; then + pid="$(cat "$pid_file")" + if kill -0 "$pid" 2>/dev/null; then + # npm spawns vite as a child; take the group down with it. + kill -- -"$(ps -o pgid= -p "$pid" | tr -d ' ')" 2>/dev/null || kill "$pid" 2>/dev/null + echo "dev-down: stopped $name (pid $pid)" + fi + rm -f "$pid_file" + fi +done + +if pg_ctl -D "$PGDIR" status >/dev/null 2>&1; then + pg_ctl -D "$PGDIR" -m fast stop >/dev/null 2>&1 && echo "dev-down: stopped postgres" +fi diff --git a/scripts/dev-reap.sh b/scripts/dev-reap.sh new file mode 100755 index 0000000..9137413 --- /dev/null +++ b/scripts/dev-reap.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Reap Vite servers left behind by Playwright runs (its fixture spawns one per +# worker and orphans them on teardown). +# +# The dev stack's own Vite also runs with `--port`, so matching on that alone +# kills it too. Key on the port instead: the dev stack owns WEB_PORT, the e2e +# fixture uses random high ones. +set -uo pipefail + +WEB_PORT="${WEB_PORT:-8091}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +pids=$(ps -eo pid=,comm=,command= | awk -v me=$$ -v keep="$WEB_PORT" -v root="$ROOT" ' + $1 != me && $2 ~ /node$/ && index($0, root "/web/node_modules/.bin/vite") \ + && $0 ~ /--port/ && $0 !~ ("--port " keep) { print $1 }') + +if [ -z "$pids" ]; then + echo "dev-reap: nothing to reap" + exit 0 +fi +echo "dev-reap: killing $(echo "$pids" | wc -l | tr -d ' ') orphaned vite server(s)" +echo "$pids" | xargs kill -9 2>/dev/null diff --git a/scripts/dev-stack.sh b/scripts/dev-stack.sh new file mode 100755 index 0000000..973a9a9 --- /dev/null +++ b/scripts/dev-stack.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Local dev stack for manual testing: migrate, then run the API + Vite against +# whatever DATABASE_URL is in the environment (e.g. the ramdisk cluster). +# +# Ports are deliberately NOT the canonical 8080/8090: other worktrees of this +# repo often have `just watch-api`/`watch-web` running there, and a Vite on +# ::1:8090 plus one on 127.0.0.1:8090 can coexist and silently serve the wrong +# worktree. Override with API_PORT / WEB_PORT. +set -euo pipefail + +# PORT is set in some shells (e.g. the workhorse local server) and clap treats +# it as present, which conflicts with the API's BIND_ADDRESS. Drop it here. +unset PORT + +API_PORT="${API_PORT:-8081}" +WEB_PORT="${WEB_PORT:-8091}" + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +export PUBLIC_BASE_URL="http://127.0.0.1:${WEB_PORT}" + +echo "dev-stack: running migrations" >&2 +target/debug/migrate + +echo "dev-stack: starting API on 127.0.0.1:${API_PORT}" >&2 +BIND_ADDRESS="127.0.0.1:${API_PORT}" target/debug/pollen-server & +API_PID=$! + +echo "dev-stack: starting Vite on http://127.0.0.1:${WEB_PORT}" >&2 +( + cd web + VITE_PROXY_TARGET="http://127.0.0.1:${API_PORT}" \ + npm run dev -- --port "$WEB_PORT" --strictPort --host 127.0.0.1 +) & +WEB_PID=$! + +trap 'kill "$API_PID" "$WEB_PID" 2>/dev/null || true' EXIT INT TERM +wait diff --git a/scripts/dev-up.sh b/scripts/dev-up.sh new file mode 100755 index 0000000..698e236 --- /dev/null +++ b/scripts/dev-up.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Bring up a DETACHED local dev stack for manual testing, and return. +# +# Unlike `just watch-*` (foreground) and scripts/dev-stack.sh (dies with its +# parent shell), everything here is orphaned to init via nohup so the stack +# survives the shell -- and the agent session -- that started it. +# +# Idempotent: re-run to repair a partially-dead stack. Stop with dev-down.sh. +# +# Ports avoid the canonical 8080/8090, which other worktrees of this repo often +# hold; a Vite on ::1:8090 and one on 127.0.0.1:8090 can coexist and silently +# serve the wrong worktree. Override with API_PORT / WEB_PORT / PG_PORT. +set -euo pipefail + +unset PORT # clap treats env PORT as present, conflicting with BIND_ADDRESS + +API_PORT="${API_PORT:-8081}" +WEB_PORT="${WEB_PORT:-8091}" +PG_PORT="${PG_PORT:-5440}" +ROLE=pollen + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +RUN="$ROOT/.dev-stack" # logs + pids (gitignored via .git/info/exclude) +PGDIR="$HOME/.cache/pollen-dev-pg" +mkdir -p "$RUN" + +export DATABASE_URL="postgresql://${ROLE}@127.0.0.1:${PG_PORT}/${ROLE}" +export PUBLIC_BASE_URL="http://127.0.0.1:${WEB_PORT}" + +# ── Postgres: persistent cluster, trust auth, daemonised by pg_ctl ────────── +if [ ! -s "$PGDIR/PG_VERSION" ]; then + echo "dev-up: initialising cluster at $PGDIR" >&2 + rm -rf "$PGDIR"; mkdir -p "$PGDIR" + initdb -D "$PGDIR" -U "$ROLE" --auth=trust -E UTF8 >/dev/null +fi +if ! pg_ctl -D "$PGDIR" status >/dev/null 2>&1; then + echo "dev-up: starting postgres on 127.0.0.1:$PG_PORT" >&2 + pg_ctl -D "$PGDIR" -l "$PGDIR/server.log" -w start \ + -o "-p $PG_PORT -h 127.0.0.1 -k $PGDIR" >/dev/null +fi +createdb -h 127.0.0.1 -p "$PG_PORT" -U "$ROLE" "$ROLE" 2>/dev/null || true + +echo "dev-up: running migrations" >&2 +target/debug/migrate + +# ── API + Vite, orphaned so they outlive this shell ──────────────────────── +alive() { [ -f "$1" ] && kill -0 "$(cat "$1")" 2>/dev/null; } + +if alive "$RUN/api.pid"; then + echo "dev-up: API already running (pid $(cat "$RUN/api.pid"))" >&2 +else + echo "dev-up: starting API on 127.0.0.1:$API_PORT" >&2 + ( BIND_ADDRESS="127.0.0.1:${API_PORT}" \ + nohup target/debug/pollen-server >"$RUN/api.log" 2>&1 & + echo $! > "$RUN/api.pid" ) +fi + +if alive "$RUN/web.pid"; then + echo "dev-up: Vite already running (pid $(cat "$RUN/web.pid"))" >&2 +else + echo "dev-up: starting Vite on 127.0.0.1:$WEB_PORT" >&2 + ( cd web + VITE_PROXY_TARGET="http://127.0.0.1:${API_PORT}" \ + nohup npm run dev -- --port "$WEB_PORT" --strictPort --host 127.0.0.1 \ + >"$RUN/web.log" 2>&1 & + echo $! > "$RUN/web.pid" ) +fi + +# ── Wait for both, through the proxy path the browser actually uses ───────── +for _ in $(seq 1 60); do + if curl -sf -o /dev/null "http://127.0.0.1:${API_PORT}/livez" 2>/dev/null \ + && curl -sf -o /dev/null "http://127.0.0.1:${WEB_PORT}/" 2>/dev/null; then + echo + echo " dev stack up -> http://127.0.0.1:${WEB_PORT}" + echo " api http://127.0.0.1:${API_PORT} db $DATABASE_URL" + echo " logs $RUN/{api,web}.log stop: scripts/dev-down.sh" + exit 0 + fi + sleep 1 +done + +echo "dev-up: stack did not come up; tail of logs:" >&2 +tail -20 "$RUN/api.log" "$RUN/web.log" 2>/dev/null >&2 +exit 1 diff --git a/web/e2e/wizard.spec.ts b/web/e2e/wizard.spec.ts index 7f2dd6e..76c7c46 100644 --- a/web/e2e/wizard.spec.ts +++ b/web/e2e/wizard.spec.ts @@ -86,6 +86,16 @@ test("walks a complete plan to a finalised artifact", async ({ page }) => { await expect(page.getByRole("button", { name: "Warnings" })).toBeHidden(); // The standard domain arrangement is BES's to set up. await expect(page.getByText("Provision the tamanu.app name and its certificates")).toBeVisible(); + + // Compute requirements list the classes this deployment needs provisioned: + // client-hosted facilities and the workstations staff use. Central is BES + // cloud here, and there are no mobile users, so neither appears. + const compute = page.locator("#s-compute"); + await expect(compute.getByRole("heading", { name: "Compute requirements" })).toBeVisible(); + await expect(compute.getByText("Facility server", { exact: true })).toBeVisible(); + await expect(compute.getByText("User devices", { exact: true })).toBeVisible(); + await expect(compute.getByText("Central server", { exact: true })).toBeHidden(); + await expect(compute.getByText("Mobile devices", { exact: true })).toBeHidden(); }); test("'Make changes' opens the new version in a new tab", async ({ page, context }) => { diff --git a/web/openapi.json b/web/openapi.json index 55578d3..e0ee350 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -419,6 +419,7 @@ "derived", "visible_questions", "consequences", + "requirements", "guidance", "assumed", "open_items", @@ -471,6 +472,13 @@ }, "description": "Visible questions that must be answered: no default to fall back on and\nno unsure option to decline with. These block finalising." }, + "requirements": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TriggeredRequirement" + }, + "description": "The compute requirements for the classes present in the deployment, in\nruleset order (spec WIZ, Compute requirements)." + }, "verdict": { "$ref": "#/components/schemas/Verdict" }, @@ -722,6 +730,22 @@ "Blocking" ] }, + "Spec": { + "type": "object", + "description": "One row of a compute requirement as presented: the engine has already\nresolved the size band and dropped the rows whose condition does not hold.", + "required": [ + "label", + "value" + ], + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, "Status": { "type": "string", "description": "The technical-versus-contractual line (spec WIZ, Status).", @@ -765,6 +789,41 @@ } } }, + "TriggeredRequirement": { + "type": "object", + "description": "A compute requirement whose class is present in the deployment. Carries the\nprofile's content (the `when` condition that selected it is not on the wire).", + "required": [ + "id", + "class", + "specs" + ], + "properties": { + "class": { + "type": "string" + }, + "id": { + "type": "string" + }, + "note": { + "type": [ + "string", + "null" + ] + }, + "specs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Spec" + } + }, + "summary": { + "type": [ + "string", + "null" + ] + } + } + }, "Verdict": { "type": "string", "description": "The viability verdict: the worst severity present across triggered\nconsequences. Default-severity consequences don't move it off `Clear`.", diff --git a/web/src/api-types.ts b/web/src/api-types.ts index 2d74e50..83153fb 100644 --- a/web/src/api-types.ts +++ b/web/src/api-types.ts @@ -208,6 +208,11 @@ export interface components { * no unsure option to decline with. These block finalising. */ required: string[]; + /** + * @description The compute requirements for the classes present in the deployment, in + * ruleset order (spec WIZ, Compute requirements). + */ + requirements: components["schemas"]["TriggeredRequirement"][]; verdict: components["schemas"]["Verdict"]; /** * @description The ids of questions currently shown, in ruleset order (spec WIZ, @@ -318,6 +323,14 @@ export interface components { * @enum {string} */ Severity: "Default" | "NonDefault" | "Blocking"; + /** + * @description One row of a compute requirement as presented: the engine has already + * resolved the size band and dropped the rows whose condition does not hold. + */ + Spec: { + label: string; + value: string; + }; /** * @description The technical-versus-contractual line (spec WIZ, Status). * @enum {string} @@ -332,6 +345,17 @@ export interface components { at: string; message: string; }; + /** + * @description A compute requirement whose class is present in the deployment. Carries the + * profile's content (the `when` condition that selected it is not on the wire). + */ + TriggeredRequirement: { + class: string; + id: string; + note?: string | null; + specs: components["schemas"]["Spec"][]; + summary?: string | null; + }; /** * @description The viability verdict: the worst severity present across triggered * consequences. Default-severity consequences don't move it off `Clear`. diff --git a/web/src/app.css b/web/src/app.css index 966ec96..22dac58 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -507,6 +507,100 @@ body { color: var(--ink-soft); } +/* ── Section heading (heads a stack of sibling sections) ─────────────── */ +.sheet-heading { + padding: 20px 30px 0; +} +.sheet-caption { + margin: 0; + font-size: 12.5px; + color: var(--ink-faint); + line-height: 1.5; +} +.sheet-section-title + .sheet-caption { + margin-top: -7px; +} + +/* ── Compute requirements ────────────────────────────────────────────── */ +.reqs { + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 14px; +} +.req { + border: 1px solid var(--line); + border-radius: 11px; + background: var(--surface); + box-shadow: + 0 1px 2px rgba(20, 34, 43, 0.03), + 0 12px 28px -22px rgba(20, 34, 43, 0.16); + overflow: hidden; +} +.req-head { + padding: 13px 16px 11px; + border-bottom: 1px solid var(--line-soft); +} +.req-class { + margin: 0; + font-family: var(--font-display); + font-size: 15px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--ink); +} +.req-summary { + margin: 3px 0 0; + font-size: 12.5px; + color: var(--ink-soft); + line-height: 1.45; +} +/* A spec sheet: fixed label column so the figures line up down the card. */ +.req-specs { + margin: 0; +} +.req-spec { + display: grid; + grid-template-columns: 136px 1fr; + gap: 16px; + padding: 9px 16px; + font-size: 12.5px; + border-top: 1px solid var(--line-soft); +} +.req-spec:first-child { + border-top: none; +} +.req-spec dt { + color: var(--ink-faint); +} +.req-spec dd { + margin: 0; + color: var(--ink); + font-weight: 500; + line-height: 1.45; +} +/* Advisory, so it takes the sky informational wash rather than a severity hue. */ +.req-note { + margin: 0; + padding: 10px 16px; + background: var(--sky-tint); + border-top: 1px solid #bfe4f8; + font-size: 12px; + color: var(--navy-deep); + line-height: 1.45; +} +.req-note a { + color: var(--sky-deep); + text-decoration: underline; + text-underline-offset: 2px; +} +@media (max-width: 560px) { + .req-spec { + grid-template-columns: 1fr; + gap: 2px; + } +} + /* ── Splash / status ─────────────────────────────────────────────────── */ .splash { display: grid; @@ -567,7 +661,8 @@ body { .sheet-section, .item, .card, - .record { + .record, + .req { break-inside: avoid; } /* The entrance animation (opacity 0→1, fill both) can otherwise be captured diff --git a/web/src/components/Artifact.tsx b/web/src/components/Artifact.tsx index dbf67b2..8dae2eb 100644 --- a/web/src/components/Artifact.tsx +++ b/web/src/components/Artifact.tsx @@ -13,10 +13,11 @@ import { } from "../types"; import { listDone, recordDone } from "../doneItems"; import { Chevron, ConsequenceCard, VerdictBanner } from "./visuals"; +import { Markup } from "../markup"; -// Warnings come first, so what the client is opting into is met before the work -// it implies, but collapsed: they are context for the actions below, not the -// task. They carry the off-default colour and run straight into those actions. +// Warnings lead the sheet, so what the client is opting into is met before +// anything else, but collapsed: they are context rather than a task. They carry +// the off-default colour they carry everywhere else. const AUDIENCE_ORDER: Audience[] = ["Record", "Client", "Bes", "Pricing"]; const COLLAPSED_ON_ARRIVAL: Audience[] = ["Record"]; const WARNINGS: Audience = "Record"; @@ -59,6 +60,10 @@ export default function Artifact({ view }: { view: AppView }) { () => groupConsequences(ev.consequences).filter((g) => g.items.length > 0), [ev.consequences], ); + // Warnings lead the sheet: what the client is opting into is met before + // anything else. The rest are the work, and sit under "Next steps". + const warningGroup = groups.find((g) => g.key === WARNINGS); + const actionGroups = groups.filter((g) => g.key !== WARNINGS); async function makeNewVersion() { // Open the tab synchronously within the click so it isn't popup-blocked, @@ -93,6 +98,41 @@ export default function Artifact({ view }: { view: AppView }) { ); } + function renderGroup(g: Group) { + const open = !shut[g.key]; + const warn = g.key === WARNINGS; + return ( +
+ + {/* Always rendered, hidden with CSS: printing must carry the whole + record however the reader reached the print dialog. */} +
+ {g.items.map((c) => ( + toggleDone(c.id)} + /> + ))} +
+
+ ); + } + return (
@@ -125,6 +165,8 @@ export default function Artifact({ view }: { view: AppView }) {
)} + {warningGroup && renderGroup(warningGroup)} + {interim && (

To confirm with BES

@@ -139,47 +181,55 @@ export default function Artifact({ view }: { view: AppView }) {
)} + {ev.requirements.length > 0 && ( +
+

Compute requirements

+

What this deployment needs, sized from your answers.

+
+ {ev.requirements.map((r) => ( +
+
+

{r.class}

+ {r.summary && ( +

+ +

+ )} +
+
+ {r.specs.map((s) => ( +
+
{s.label}
+
{s.value}
+
+ ))} +
+ {r.note && ( +

+ +

+ )} +
+ ))} +
+
+ )} + + - {groups.length === 0 ? ( + {actionGroups.length > 0 && ( +
+

Next steps

+

Who needs to do what to stand this deployment up.

+
+ )} + + {groups.length === 0 && (

No consequences match your search.

- ) : ( - groups.map((g) => { - const open = !shut[g.key]; - const warn = g.key === WARNINGS; - return ( -
- - {/* Always rendered, hidden with CSS: printing must carry the - whole record however the reader reached the print dialog. */} -
- {g.items.map((c) => ( - toggleDone(c.id)} - /> - ))} -
-
- ); - }) )} + {actionGroups.map(renderGroup)} {ev.assumed.length > 0 && (
diff --git a/web/src/types.ts b/web/src/types.ts index b0754e6..d5660fe 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -73,6 +73,8 @@ export type QuestionKind = Solidify; export type ApplicationStatus = Solidify; export type Evaluation = Solidify; export type TriggeredConsequence = Solidify; +export type TriggeredRequirement = Solidify; +export type Spec = Solidify; export type TriggeredGuidance = Solidify; export type Consequence = Solidify; export type Cost = Solidify;