diff --git a/Cargo.lock b/Cargo.lock index 70db6be92..40b91a865 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -534,13 +534,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -3715,6 +3715,7 @@ dependencies = [ "chrono", "fluent-syntax 0.11.1", "fluent-templates", + "form_urlencoded", "helios-auth", "helios-fhir", "helios-fhir-validator", @@ -7524,6 +7525,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 689dd900f..ee0f2d1cc 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -62,6 +62,9 @@ chrono.workspace = true # Bulk Import workspace (#527). uuid = { version = "1", features = ["v4"] } jsonwebtoken = "9" +# Hand-parsing the export form: repeated checkbox fields need more than +# serde_urlencoded offers (#537). +form_urlencoded = "1" # RFC 7638 thumbprint derivation for the bulk-submit signing key kid (#529). p384 = { version = "0.13", features = ["pem", "pkcs8"] } sha2 = "0.10" diff --git a/crates/ui/assets/app.css b/crates/ui/assets/app.css index b8e135584..fa189ace7 100644 --- a/crates/ui/assets/app.css +++ b/crates/ui/assets/app.css @@ -4260,3 +4260,32 @@ body.has-nav-panel { border-color: #C0362C; box-shadow: 0 0 0 3px rgb(192 54 44 / 12%); } + +/* Bulk Export (#537): the resource-type checkbox grid and job-status chips. */ +.typegrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 6px 18px; + padding: 6px 0; +} + +.typegrid__item { + font-size: 14px; + display: flex; + gap: 8px; + align-items: center; +} + +.chip { + display: inline-block; + font-size: 12px; + font-weight: 600; + border-radius: 999px; + padding: 2px 12px; + white-space: nowrap; +} + +.chip--in-progress { color: #B45309; background: rgb(180 83 9 / 12%); } +.chip--complete { color: var(--accent, #0E7C6B); background: rgb(14 124 107 / 12%); } +.chip--failed { color: #C0362C; background: rgb(192 54 44 / 12%); } +.chip--cancelled { color: var(--muted, #667); background: rgb(120 130 130 / 14%); } diff --git a/crates/ui/e2e/tests/chrome.spec.ts b/crates/ui/e2e/tests/chrome.spec.ts index 2840a7aa3..9b6232881 100644 --- a/crates/ui/e2e/tests/chrome.spec.ts +++ b/crates/ui/e2e/tests/chrome.spec.ts @@ -42,9 +42,9 @@ test("there is no expand/collapse toggle", async ({ page }) => { test("the Batch & Data section lists Import and Export", async ({ page, chrome }) => { await page.goto("/ui", { waitUntil: "networkidle" }); await chrome.sidebar.hover(); - // Import went live with the Bulk Import workspace (#527); Export and - // SQL-on-FHIR are still placeholders. + // Import (#527) and Export (#537) are live workspaces; SQL-on-FHIR is + // still a placeholder. await expect(chrome.navLink("/ui/bulk-import")).toBeVisible(); - await expect(chrome.soonItem("Export")).toBeVisible(); + await expect(chrome.navLink("/ui/bulk-export")).toBeVisible(); await expect(chrome.soonItem("SQL-on-FHIR")).toBeVisible(); }); diff --git a/crates/ui/src/bulk_export.rs b/crates/ui/src/bulk_export.rs new file mode 100644 index 000000000..1f259fed6 --- /dev/null +++ b/crates/ui/src/bulk_export.rs @@ -0,0 +1,580 @@ +//! Bulk Export workspace (`/ui/bulk-export`) — driving HFS's own `$export` +//! operation (#537). +//! +//! The pull-based companion to the Bulk Import workspace: the user picks a +//! scope (everything / patients / group), the resource types, and the +//! narrowing filters; the workspace kicks off the server's async `$export`, +//! then tracks the job on the Active Exports page — one server-side status +//! poll per htmx fetch, exactly like the import workspace's recipient +//! polling. +//! +//! Kick-offs and polls are self-calls: the target is this same server, +//! addressed via the request's `Host` header, with the caller's own +//! `Authorization` and tenant forwarded — so the export runs with the +//! user's credentials, not a service account. +//! +//! Job state lives in the per-user settings document under +//! `byTenant..bulkExport.jobs`, object-keyed by id. + +use askama::Template; +use axum::{ + Extension, + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Redirect, Response}, +}; +use chrono::{Duration, SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::i18n::{I18n, RequestLocale}; +use crate::{RequestTenant, RequestVersion, WebState, current_status, render, settings_user_key}; + +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ExportJob { + #[serde(default)] + pub name: String, + /// `system` | `patient` | `group`. + #[serde(default)] + pub scope: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub group_id: String, + /// Comma-separated `_type` list; empty exports every type. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub types: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub elements: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub type_filter: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub since: String, + /// `in-progress` | `complete` | `failed` | `cancelled`. + #[serde(default)] + pub status: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub poll_url: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub progress: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub error: String, + #[serde(default)] + pub started_at: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub finished_at: String, + /// Completion-manifest `output` entries (`{type, url, count?}`). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub files: Vec, +} + +async fn load_jobs( + state: &WebState, + user_key: &str, + tenant: &str, +) -> serde_json::Map { + let Some(store) = &state.settings else { + return serde_json::Map::new(); + }; + store + .get_settings(user_key) + .await + .ok() + .flatten() + .and_then(|s| { + s.document + .get("byTenant")? + .get(tenant)? + .get("bulkExport")? + .get("jobs")? + .as_object() + .cloned() + }) + .unwrap_or_default() +} + +async fn store_job( + state: &WebState, + user_key: &str, + tenant: &str, + id: &str, + job: &ExportJob, +) -> Result<(), String> { + let Some(store) = &state.settings else { + return Err("settings store unavailable".to_string()); + }; + // Null first so dropped fields (files, error) don't survive the merge. + let clear = + json!({ "byTenant": { tenant: { "bulkExport": { "jobs": { id: Value::Null } } } } }); + store + .patch_settings(user_key, clear, None) + .await + .map_err(|e| e.to_string())?; + let value = serde_json::to_value(job).map_err(|e| e.to_string())?; + let patch = json!({ "byTenant": { tenant: { "bulkExport": { "jobs": { id: value } } } } }); + store + .patch_settings(user_key, patch, None) + .await + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +fn now_stamp() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) +} + +fn parse_job(value: &Value) -> ExportJob { + serde_json::from_value(value.clone()).unwrap_or_default() +} + +/// The FHIR API base of this same server, from the request's Host header. +fn self_base(headers: &HeaderMap) -> String { + let host = headers + .get("host") + .and_then(|v| v.to_str().ok()) + .unwrap_or("localhost:8080"); + format!("http://{host}") +} + +/// Forwards the caller's credentials and tenant onto a self-call, so the +/// export runs as the user who asked for it. +fn forward_identity( + mut request: reqwest::RequestBuilder, + headers: &HeaderMap, + tenant: &str, +) -> reqwest::RequestBuilder { + if let Some(auth) = headers.get("authorization").and_then(|v| v.to_str().ok()) { + request = request.header("Authorization", auth); + } + request = request.header("X-Tenant-ID", tenant); + request +} + +// --------------------------------------------------------------------------- +// View models & templates +// --------------------------------------------------------------------------- + +struct JobCard { + id: String, + name: String, + status: String, + status_label: String, + progress: String, + error: String, + file_count: usize, + files: Vec<(String, String)>, + elapsed: String, +} + +fn status_label(i18n: &I18n, status: &str) -> String { + match status { + "complete" => i18n.t("bulk-export-status-complete"), + "failed" => i18n.t("bulk-export-status-failed"), + "cancelled" => i18n.t("bulk-export-status-cancelled"), + _ => i18n.t("bulk-export-status-in-progress"), + } +} + +/// `finished - started` as `5m 08s`, when both stamps parse. +fn elapsed(job: &ExportJob) -> String { + let (Ok(start), Ok(end)) = ( + chrono::DateTime::parse_from_rfc3339(&job.started_at), + chrono::DateTime::parse_from_rfc3339(&job.finished_at), + ) else { + return String::new(); + }; + let secs = (end - start).num_seconds().max(0); + format!("{}m {:02}s", secs / 60, secs % 60) +} + +fn job_card(i18n: &I18n, id: &str, job: &ExportJob) -> JobCard { + JobCard { + id: id.to_string(), + name: if job.name.is_empty() { + job.scope.clone() + } else { + job.name.clone() + }, + status_label: status_label(i18n, &job.status), + status: job.status.clone(), + progress: job.progress.clone(), + error: job.error.clone(), + file_count: job.files.len(), + files: job + .files + .iter() + .filter_map(|f| { + Some(( + f.get("type")?.as_str()?.to_string(), + f.get("url")?.as_str()?.to_string(), + )) + }) + .collect(), + elapsed: elapsed(job), + } +} + +#[derive(Template)] +#[template(path = "pages/bulk-export.html")] +struct BulkExportPage { + status: crate::Status, + i18n: I18n, + active_page: &'static str, + available: bool, + resource_types: Vec, + active_count: usize, + error: Option, +} + +#[derive(Template)] +#[template(path = "pages/bulk-export-active.html")] +struct ActiveExportsPage { + status: crate::Status, + i18n: I18n, + active_page: &'static str, + total: usize, + running: usize, + cards: Vec, +} + +#[derive(Template)] +#[template(path = "partials/bulk_export_card.html")] +struct JobCardFragment { + i18n: I18n, + card: JobCard, +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// `GET /ui/bulk-export` — the export builder. +pub async fn page( + State(state): State, + locale: RequestLocale, + rv: RequestVersion, + rt: RequestTenant, + principal: Option>, +) -> Response { + let i18n = I18n::new(locale); + let status = current_status(state.version, rv.0, &rt); + let user_key = settings_user_key(principal.as_deref()); + // Like the other three callers, the version here is a cache key only — + // the list reflects the server's seeded version regardless of the sidebar + // selector (see resource_type_names' caveat). + let resource_types = state + .compartments + .resource_type_names(&rt.id, helios_fhir::FhirVersion::default()) + .await; + let active_count = load_jobs(&state, &user_key, &rt.id) + .await + .values() + .filter(|j| j["status"] == "in-progress") + .count(); + render(BulkExportPage { + status, + i18n, + active_page: "bulk-export", + available: state.settings.is_some(), + resource_types, + active_count, + error: None, + }) +} + +/// The export form. Parsed by hand: the resource-type checkboxes arrive as +/// repeated `types` fields, which `axum::Form`'s serde_urlencoded rejects. +#[derive(Default)] +pub struct StartForm { + pub name: String, + pub scope: String, + pub group_id: String, + pub types: Vec, + pub elements: String, + pub type_filter: String, + pub since_preset: String, + pub since_custom: String, +} + +fn parse_start_form(body: &str) -> StartForm { + let mut form = StartForm::default(); + for (key, value) in form_urlencoded::parse(body.as_bytes()) { + let value = value.into_owned(); + match key.as_ref() { + "name" => form.name = value, + "scope" => form.scope = value, + "group_id" => form.group_id = value, + "types" => form.types.push(value), + "elements" => form.elements = value, + "type_filter" => form.type_filter = value, + "since_preset" => form.since_preset = value, + "since_custom" => form.since_custom = value, + _ => {} + } + } + form +} + +/// Maps the Since preset (or the custom stamp) onto an `_since` instant. +fn since_instant(preset: &str, custom: &str) -> String { + let ago = |d: Duration| (Utc::now() - d).to_rfc3339_opts(SecondsFormat::Secs, true); + match preset { + "day" => ago(Duration::days(1)), + "week" => ago(Duration::days(7)), + "month" => ago(Duration::weeks(4)), + "custom" => custom.trim().to_string(), + _ => String::new(), + } +} + +/// `POST /ui/bulk-export` — kick off the export, then land on Active Exports. +pub async fn start( + State(state): State, + rt: RequestTenant, + principal: Option>, + headers: HeaderMap, + axum::extract::RawForm(body): axum::extract::RawForm, +) -> Response { + let form = parse_start_form(&String::from_utf8_lossy(&body)); + let user_key = settings_user_key(principal.as_deref()); + let mut job = ExportJob { + name: form.name.trim().to_string(), + scope: match form.scope.as_str() { + "patient" | "group" => form.scope.clone(), + _ => "system".to_string(), + }, + group_id: form.group_id.trim().to_string(), + types: form.types.join(","), + elements: form.elements.trim().to_string(), + type_filter: form.type_filter.trim().to_string(), + since: since_instant(&form.since_preset, &form.since_custom), + status: "in-progress".to_string(), + started_at: now_stamp(), + ..Default::default() + }; + let id = uuid::Uuid::new_v4().to_string(); + kickoff(&mut job, &headers, &rt.id).await; + let _ = store_job(&state, &user_key, &rt.id, &id, &job).await; + Redirect::to("/ui/bulk-export/active").into_response() +} + +/// Performs the `$export` kick-off self-call, recording the poll URL or the +/// failure on the job. +async fn kickoff(job: &mut ExportJob, headers: &HeaderMap, tenant: &str) { + let base = self_base(headers); + let path = match job.scope.as_str() { + "patient" => format!("{base}/Patient/$export"), + "group" => format!("{base}/Group/{}/$export", job.group_id), + _ => format!("{base}/$export"), + }; + let mut query: Vec<(&str, &str)> = Vec::new(); + if !job.types.is_empty() { + query.push(("_type", &job.types)); + } + if !job.elements.is_empty() { + query.push(("_elements", &job.elements)); + } + if !job.type_filter.is_empty() { + query.push(("_typeFilter", &job.type_filter)); + } + if !job.since.is_empty() { + query.push(("_since", &job.since)); + } + let request = forward_identity( + reqwest::Client::new() + .get(&path) + .query(&query) + .header("Accept", "application/fhir+json") + .header("Prefer", "respond-async") + .timeout(std::time::Duration::from_secs(15)), + headers, + tenant, + ); + match request.send().await { + Ok(response) if response.status().as_u16() == 202 => { + match response + .headers() + .get("content-location") + .and_then(|v| v.to_str().ok()) + { + // A relative Content-Location is resolved against this server. + Some(poll) if poll.starts_with('/') => { + job.poll_url = format!("{base}{poll}"); + } + Some(poll) => job.poll_url = poll.to_string(), + None => { + job.status = "failed".to_string(); + job.error = "kick-off accepted without a Content-Location".to_string(); + } + } + } + Ok(response) => { + let code = response.status().as_u16(); + let mut body = response.text().await.unwrap_or_default(); + body.truncate(300); + job.status = "failed".to_string(); + job.error = format!("kick-off answered {code}: {}", body.replace('\n', " ")); + } + Err(e) => { + job.status = "failed".to_string(); + job.error = e.to_string(); + } + } +} + +/// `GET /ui/bulk-export/active` — the job list. +pub async fn active( + State(state): State, + locale: RequestLocale, + rv: RequestVersion, + rt: RequestTenant, + principal: Option>, +) -> Response { + let i18n = I18n::new(locale); + let status = current_status(state.version, rv.0, &rt); + let user_key = settings_user_key(principal.as_deref()); + let jobs = load_jobs(&state, &user_key, &rt.id).await; + let mut entries: Vec<(String, ExportJob)> = jobs + .iter() + .map(|(id, v)| (id.clone(), parse_job(v))) + .collect(); + entries.sort_by(|a, b| b.1.started_at.cmp(&a.1.started_at)); + let running = entries + .iter() + .filter(|(_, j)| j.status == "in-progress") + .count(); + let cards = entries + .iter() + .map(|(id, j)| job_card(&i18n, id, j)) + .collect(); + render(ActiveExportsPage { + status, + i18n: I18n::new(locale), + active_page: "bulk-export", + total: entries.len(), + running, + cards, + }) +} + +/// `GET /ui/bulk-export/active/{id}/card` — one poll, then the refreshed card. +pub async fn card( + State(state): State, + locale: RequestLocale, + rt: RequestTenant, + principal: Option>, + headers: HeaderMap, + Path(id): Path, +) -> Response { + let i18n = I18n::new(locale); + let user_key = settings_user_key(principal.as_deref()); + let jobs = load_jobs(&state, &user_key, &rt.id).await; + let Some(mut job) = jobs.get(&id).map(parse_job) else { + return StatusCode::NOT_FOUND.into_response(); + }; + if job.status == "in-progress" && !job.poll_url.is_empty() { + poll_job(&mut job, &headers, &rt.id).await; + let _ = store_job(&state, &user_key, &rt.id, &id, &job).await; + } + let card = job_card(&i18n, &id, &job); + render(JobCardFragment { i18n, card }) +} + +/// One poll of the export status endpoint. +async fn poll_job(job: &mut ExportJob, headers: &HeaderMap, tenant: &str) { + let request = forward_identity( + reqwest::Client::new() + .get(&job.poll_url) + .header("Accept", "application/fhir+json") + .timeout(std::time::Duration::from_secs(10)), + headers, + tenant, + ); + let response = match request.send().await { + Ok(r) => r, + Err(e) => { + job.status = "failed".to_string(); + job.error = format!("status poll failed: {e}"); + return; + } + }; + match response.status().as_u16() { + 202 => { + job.progress = response + .headers() + .get("x-progress") + .and_then(|v| v.to_str().ok()) + .unwrap_or("in progress") + .to_string(); + } + 200 => { + let manifest: Value = response.json().await.unwrap_or(Value::Null); + job.files = manifest["output"].as_array().cloned().unwrap_or_default(); + job.status = "complete".to_string(); + job.finished_at = now_stamp(); + job.progress = String::new(); + } + code => { + let mut body = response.text().await.unwrap_or_default(); + body.truncate(300); + job.status = "failed".to_string(); + job.error = format!("{code}: {}", body.replace('\n', " ")); + } + } +} + +/// `POST /ui/bulk-export/active/{id}/cancel` — DELETE against the poll URL. +pub async fn cancel( + State(state): State, + rt: RequestTenant, + principal: Option>, + headers: HeaderMap, + Path(id): Path, +) -> Response { + let user_key = settings_user_key(principal.as_deref()); + let jobs = load_jobs(&state, &user_key, &rt.id).await; + if let Some(mut job) = jobs.get(&id).map(parse_job) { + if !job.poll_url.is_empty() { + let request = forward_identity( + reqwest::Client::new() + .delete(&job.poll_url) + .timeout(std::time::Duration::from_secs(10)), + &headers, + &rt.id, + ); + let _ = request.send().await; + } + job.status = "cancelled".to_string(); + job.finished_at = now_stamp(); + job.progress = String::new(); + let _ = store_job(&state, &user_key, &rt.id, &id, &job).await; + } + Redirect::to("/ui/bulk-export/active").into_response() +} + +/// `POST /ui/bulk-export/active/{id}/retry` — same parameters, fresh kick-off. +pub async fn retry( + State(state): State, + rt: RequestTenant, + principal: Option>, + headers: HeaderMap, + Path(id): Path, +) -> Response { + let user_key = settings_user_key(principal.as_deref()); + let jobs = load_jobs(&state, &user_key, &rt.id).await; + if let Some(mut job) = jobs.get(&id).map(parse_job) { + job.status = "in-progress".to_string(); + job.error = String::new(); + job.progress = String::new(); + job.files = Vec::new(); + job.poll_url = String::new(); + job.finished_at = String::new(); + job.started_at = now_stamp(); + kickoff(&mut job, &headers, &rt.id).await; + let _ = store_job(&state, &user_key, &rt.id, &id, &job).await; + } + Redirect::to("/ui/bulk-export/active").into_response() +} diff --git a/crates/ui/src/compartments.rs b/crates/ui/src/compartments.rs index 9a2db6777..ada0f28f1 100644 --- a/crates/ui/src/compartments.rs +++ b/crates/ui/src/compartments.rs @@ -128,6 +128,13 @@ impl CompartmentCatalog { /// Every resource type of the version, from the first CompartmentDefinition /// (each enumerates the full set — 145 in R4). Used by the queries page's /// resource picker rail. + /// + /// Caveat: `version` is a **cache key only**. The production + /// [`HttpConformanceSource`](crate::conformance) discards its version + /// argument, so the list always reflects whatever version the server + /// seeded (`HFS_FHIR_VERSION`) — the sidebar's version selector does not + /// change it. Correct on a single-version deployment, which is the common + /// case; making the version real end-to-end is tracked separately. pub async fn resource_type_names(&self, tenant: &str, version: FhirVersion) -> Vec { self.definitions(tenant, version) .await diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 0e5a64b95..22378d070 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -36,6 +36,7 @@ //! selector. Both selectors are plain links, so the dashboard stays navigable //! without JavaScript. +mod bulk_export; mod bulk_import; mod compartments; mod conformance; @@ -673,6 +674,20 @@ pub fn mount_with_conformance_source( // docs/history-diff-rendering.md); the browser posts the two versions // it fetched from `_history`. .route("/ui/history/diff", axum::routing::post(history_diff)) + .route( + "/ui/bulk-export", + get(bulk_export::page).post(bulk_export::start), + ) + .route("/ui/bulk-export/active", get(bulk_export::active)) + .route("/ui/bulk-export/active/{id}/card", get(bulk_export::card)) + .route( + "/ui/bulk-export/active/{id}/cancel", + axum::routing::post(bulk_export::cancel), + ) + .route( + "/ui/bulk-export/active/{id}/retry", + axum::routing::post(bulk_export::retry), + ) .route( "/ui/bulk-import", get(bulk_import::page).post(bulk_import::create), diff --git a/crates/ui/templates/layouts/base.html b/crates/ui/templates/layouts/base.html index 4d7374720..4f976c853 100644 --- a/crates/ui/templates/layouts/base.html +++ b/crates/ui/templates/layouts/base.html @@ -86,10 +86,10 @@ {% include "icons/import.svg" %} {{ i18n.t("nav-import") }} - + {% include "icons/export.svg" %} {{ i18n.t("nav-export") }} - + {% include "icons/grid.svg" %} {{ i18n.t("nav-sql-on-fhir") }} diff --git a/crates/ui/templates/pages/bulk-export-active.html b/crates/ui/templates/pages/bulk-export-active.html new file mode 100644 index 000000000..5ab9e7015 --- /dev/null +++ b/crates/ui/templates/pages/bulk-export-active.html @@ -0,0 +1,26 @@ +{% extends "layouts/base.html" %} + +{% block title %}{{ i18n.t("bulk-export-active-title") }} — {{ i18n.t("app-title") }}{% endblock %} + +{% block breadcrumb %}{% endblock %} + +{% block content %} +
+
+ ‹ {{ i18n.t("bulk-export-title") }} +

+ {{ i18n.t("bulk-export-active-title") }} + {{ total }} · {{ running }} {{ i18n.t("bulk-export-running") }} +

+
+ {{ i18n.t("bulk-export-new") }} +
+ +{% if cards.is_empty() %} +
{{ i18n.t("bulk-export-none") }}
+{% endif %} + +{% for card in cards %} +{% include "partials/bulk_export_card.html" %} +{% endfor %} +{% endblock %} diff --git a/crates/ui/templates/pages/bulk-export.html b/crates/ui/templates/pages/bulk-export.html new file mode 100644 index 000000000..79fc7ff33 --- /dev/null +++ b/crates/ui/templates/pages/bulk-export.html @@ -0,0 +1,103 @@ +{% extends "layouts/base.html" %} + +{% block title %}{{ i18n.t("bulk-export-title") }} — {{ i18n.t("app-title") }}{% endblock %} + +{% block breadcrumb %}{% endblock %} + +{% block content %} +
+

{{ i18n.t("bulk-export-title") }}

+ + {{ i18n.t("bulk-export-active-link") }} + {{ active_count }} + +
+ +{% if !available %} +
{{ i18n.t("bulk-export-unavailable") }}
+{% else %} + +{% match error %}{% when Some(message) %} +
{{ message }}
+{% when None %}{% endmatch %} + +
+
+

{{ i18n.t("bulk-export-scope") }}

+ + + + + +
+ +
+
+

{{ i18n.t("bulk-export-types") }}

+
+
+ {% for t in resource_types %} + + {% endfor %} +
+

{{ i18n.t("bulk-export-types-hint") }}

+
+ +
+

{{ i18n.t("bulk-export-narrow") }}

+ + + + +
+ +
+ +
+
+{% endif %} +{% endblock %} diff --git a/crates/ui/templates/pages/bulk-import-detail.html b/crates/ui/templates/pages/bulk-import-detail.html index fb27ca632..2ea946aba 100644 --- a/crates/ui/templates/pages/bulk-import-detail.html +++ b/crates/ui/templates/pages/bulk-import-detail.html @@ -5,7 +5,7 @@ {% block breadcrumb %}{% endblock %} {% block content %} -
+
‹ {{ i18n.t("bulk-import-all") }}

{{ name }}

diff --git a/crates/ui/templates/pages/bulk-import.html b/crates/ui/templates/pages/bulk-import.html index 997464dd8..9e958a286 100644 --- a/crates/ui/templates/pages/bulk-import.html +++ b/crates/ui/templates/pages/bulk-import.html @@ -5,7 +5,7 @@ {% block breadcrumb %}{% endblock %} {% block content %} -
+

{{ i18n.t("bulk-import-title") }}

{% if available %} diff --git a/crates/ui/templates/partials/bulk_export_card.html b/crates/ui/templates/partials/bulk_export_card.html new file mode 100644 index 000000000..3936a9087 --- /dev/null +++ b/crates/ui/templates/partials/bulk_export_card.html @@ -0,0 +1,47 @@ +{% if card.status == "in-progress" %} +
+{% else %} +
+{% endif %} +
+

{{ card.name }}

+ {{ card.status_label }} +
+ + {% if card.status == "in-progress" %} +
+ {{ i18n.t("bulk-export-progress") }} +
{% if !card.progress.is_empty() %}{{ card.progress }}{% else %}{{ i18n.t("bulk-export-progress-waiting") }}{% endif %}
+
+
+ +
+ {% endif %} + + {% if card.status == "complete" %} +
+ {{ i18n.t("bulk-export-files") }} +
{{ card.file_count }}{% if !card.elapsed.is_empty() %} · {{ i18n.t("bulk-export-finished-in") }} {{ card.elapsed }}{% endif %}
+
+ {% if !card.files.is_empty() %} +
    + {% for f in card.files %} +
  • {{ f.0 }}
  • + {% endfor %} +
+ {% endif %} + {% endif %} + + {% if card.status == "failed" %} +
+ {{ i18n.t("bulk-export-error") }} +
{{ card.error }}
+
+
+ +
+ {% endif %} +
diff --git a/crates/ui/tests/bulk_export_http.rs b/crates/ui/tests/bulk_export_http.rs new file mode 100644 index 000000000..946a7e466 --- /dev/null +++ b/crates/ui/tests/bulk_export_http.rs @@ -0,0 +1,274 @@ +//! End-to-end tests for the Bulk Export workspace (`/ui/bulk-export`, #537). +//! +//! The workspace drives the server's own `$export` API through self-calls +//! addressed by the request's Host header, so these tests mount the UI over a +//! mock FHIR export backend and serve the whole thing on a real socket: the +//! kick-off and status polls loop back into the mock. + +use std::sync::{Arc, Mutex}; + +use axum::extract::State as AxState; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::{Json, Router}; +use helios_fhir::FhirVersion; +use helios_persistence::backends::sqlite::SqliteBackend; +use helios_persistence::core::SettingsStore; + +#[derive(Clone, Default)] +struct MockExport { + /// (path, query) of each kick-off received. + kickoffs: Arc>>, + /// Polls answered so far; the first responds 202, later ones 200. + polls: Arc>, + /// When set, kick-offs answer 400 with this body. + reject: Arc>>, + cancels: Arc>, +} + +fn mock_fhir_app(state: MockExport) -> Router { + async fn kickoff( + AxState(s): AxState, + uri: axum::http::Uri, + ) -> axum::response::Response { + s.kickoffs.lock().unwrap().push(( + uri.path().to_string(), + uri.query().unwrap_or("").to_string(), + )); + if let Some(body) = s.reject.lock().unwrap().clone() { + return (StatusCode::BAD_REQUEST, body).into_response(); + } + ( + StatusCode::ACCEPTED, + [("content-location", "/export-status/job-1".to_string())], + "", + ) + .into_response() + } + async fn status(AxState(s): AxState) -> axum::response::Response { + let mut polls = s.polls.lock().unwrap(); + *polls += 1; + if *polls == 1 { + (StatusCode::ACCEPTED, [("x-progress", "18% complete")], "").into_response() + } else { + Json(serde_json::json!({ + "transactionTime": "2026-08-11T10:00:00Z", + "request": "http://x/$export", + "requiresAccessToken": false, + "output": [ + {"type": "Patient", "url": "http://x/files/Patient.ndjson"}, + {"type": "Observation", "url": "http://x/files/Observation.ndjson"} + ], + "error": [] + })) + .into_response() + } + } + async fn cancel(AxState(s): AxState) -> StatusCode { + *s.cancels.lock().unwrap() += 1; + StatusCode::ACCEPTED + } + Router::new() + .route("/$export", axum::routing::get(kickoff)) + .route("/Patient/$export", axum::routing::get(kickoff)) + .route("/Group/{id}/$export", axum::routing::get(kickoff)) + .route( + "/export-status/{id}", + axum::routing::get(status).delete(cancel), + ) + .with_state(state) +} + +/// Serves the mounted UI (over the mock FHIR app) on a real port; returns the +/// base URL and the mock's state handles. +async fn serve() -> (String, MockExport) { + let backend = SqliteBackend::in_memory().expect("in-memory sqlite"); + backend.init_schema().expect("init schema"); + let settings: Arc = Arc::new(backend); + + let mock = MockExport::default(); + let app = helios_ui::mount_with_conformance_source( + mock_fhir_app(mock.clone()), + "9.9.9", + Some(std::path::PathBuf::from("../../data")), + helios_ui::NlSearch::default(), + None, + Some(settings), + "default".to_string(), + Arc::new(helios_ui::StaticConformanceSource::from_data_dir( + std::path::Path::new("../../data"), + )), + FhirVersion::R4, + None, + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), mock) +} + +fn client() -> reqwest::Client { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap() +} + +async fn get_text(base: &str, path: &str) -> (u16, String) { + let res = client().get(format!("{base}{path}")).send().await.unwrap(); + (res.status().as_u16(), res.text().await.unwrap()) +} + +async fn post_form(base: &str, path: &str, form: &[(&str, &str)]) -> (u16, String) { + let res = client() + .post(format!("{base}{path}")) + .form(form) + .send() + .await + .unwrap(); + let location = res + .headers() + .get("location") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + (res.status().as_u16(), location) +} + +#[tokio::test] +async fn the_export_page_offers_scopes_types_and_filters() { + let (base, _) = serve().await; + let (status, html) = get_text(&base, "/ui/bulk-export").await; + assert_eq!(status, 200); + assert!(html.contains("What are you exporting?")); + assert!(html.contains("Everything")); + assert!(html.contains(r#"name="types" value="Patient""#)); + assert!(html.contains("Narrow it down")); + assert!(html.contains("Start export")); +} + +#[tokio::test] +async fn starting_a_system_export_kicks_off_and_tracks_the_job() { + let (base, mock) = serve().await; + + let (status, location) = post_form( + &base, + "/ui/bulk-export", + &[ + ("name", "Everything"), + ("scope", "system"), + ("types", "Patient"), + ("types", "Observation"), + ("elements", "id,meta"), + ("since_preset", "week"), + ], + ) + .await; + assert_eq!(status, 303); + assert_eq!(location, "/ui/bulk-export/active"); + + // The mock saw one kick-off with the narrowed parameters. + let kickoffs = mock.kickoffs.lock().unwrap().clone(); + assert_eq!(kickoffs.len(), 1); + assert_eq!(kickoffs[0].0, "/$export"); + let q = &kickoffs[0].1; + assert!(q.contains("_type=Patient%2CObservation"), "{q}"); + assert!(q.contains("_elements=id%2Cmeta"), "{q}"); + assert!(q.contains("_since="), "{q}"); + + // The Active Exports page shows it in progress. + let (_, html) = get_text(&base, "/ui/bulk-export/active").await; + assert!(html.contains("Everything")); + assert!(html.contains("In progress")); + // The card's own poll URL (not the layout's tenant-menu hx-get). + let card_path = html + .split("hx-get=\"") + .map(|s| s.split('"').next().unwrap_or("")) + .find(|s| s.starts_with("/ui/bulk-export/active/")) + .expect("card poll url") + .to_string(); + + // First card fetch: one poll -> 202 with progress, still polling. + let (_, html) = get_text(&base, &card_path).await; + assert!(html.contains("18% complete"), "{html}"); + assert!(html.contains("every 5s")); + + // Second: the mock flips to 200 -> complete with two files, no polling. + let (_, html) = get_text(&base, &card_path).await; + assert!(html.contains("Complete"), "{html}"); + assert!(html.contains("Patient.ndjson")); + assert!(html.contains("Observation.ndjson")); + assert!(!html.contains("every 5s")); +} + +#[tokio::test] +async fn patient_and_group_scopes_hit_their_export_paths() { + let (base, mock) = serve().await; + + post_form(&base, "/ui/bulk-export", &[("scope", "patient")]).await; + post_form( + &base, + "/ui/bulk-export", + &[("scope", "group"), ("group_id", "cohort-7")], + ) + .await; + + let kickoffs = mock.kickoffs.lock().unwrap().clone(); + let paths: Vec<&str> = kickoffs.iter().map(|(p, _)| p.as_str()).collect(); + assert!(paths.contains(&"/Patient/$export"), "{paths:?}"); + assert!(paths.contains(&"/Group/cohort-7/$export"), "{paths:?}"); +} + +#[tokio::test] +async fn a_rejected_kickoff_lands_as_failed_and_retry_reruns_it() { + let (base, mock) = serve().await; + *mock.reject.lock().unwrap() = + Some("The server ran out of time building Observation.ndjson".to_string()); + + post_form( + &base, + "/ui/bulk-export", + &[("name", "Diabetes registry 2024"), ("scope", "system")], + ) + .await; + + let (_, html) = get_text(&base, "/ui/bulk-export/active").await; + assert!(html.contains("Failed")); + assert!(html.contains("ran out of time")); + assert!(html.contains("Retry")); + + // Clear the failure and retry through the card's form action. + *mock.reject.lock().unwrap() = None; + let retry_path = html + .split("action=\"") + .find(|s| s.starts_with("/ui/bulk-export/active/")) + .and_then(|s| s.split('"').next()) + .expect("retry action") + .to_string(); + let (status, _) = post_form(&base, &retry_path, &[]).await; + assert_eq!(status, 303); + + let (_, html) = get_text(&base, "/ui/bulk-export/active").await; + assert!(html.contains("In progress"), "{html}"); + assert_eq!(mock.kickoffs.lock().unwrap().len(), 2); +} + +#[tokio::test] +async fn cancelling_deletes_the_job_server_side() { + let (base, mock) = serve().await; + post_form(&base, "/ui/bulk-export", &[("scope", "system")]).await; + + let (_, html) = get_text(&base, "/ui/bulk-export/active").await; + let cancel_path = html + .split("action=\"") + .find(|s| s.starts_with("/ui/bulk-export/active/") && s.contains("/cancel")) + .and_then(|s| s.split('"').next()) + .expect("cancel action") + .to_string(); + let (status, _) = post_form(&base, &cancel_path, &[]).await; + assert_eq!(status, 303); + + assert_eq!(*mock.cancels.lock().unwrap(), 1, "DELETE reached the API"); + let (_, html) = get_text(&base, "/ui/bulk-export/active").await; + assert!(html.contains("Cancelled")); +} diff --git a/locales/de/main.ftl b/locales/de/main.ftl index 99253fe3c..3e2ebafd8 100644 --- a/locales/de/main.ftl +++ b/locales/de/main.ftl @@ -596,3 +596,49 @@ editor-hint-date = FHIR date: YYYY, YYYY-MM oder YYYY-MM-DD editor-hint-datetime = FHIR dateTime: YYYY, YYYY-MM, YYYY-MM-DD oder ein vollständiger Zeitstempel mit Zeitzone (2024-05-17T14:30:00+02:00) editor-hint-time = FHIR time: HH:MM:SS editor-hint-instant = FHIR instant: vollständiger Zeitstempel mit Zeitzone, z. B. 2024-05-17T14:30:00.000Z + +## Bulk Export workspace (#537) + +bulk-export-title = Massenexport +bulk-export-active-title = Aktive Exporte +bulk-export-active-link = Aktive Exporte +bulk-export-new = Neuer Export +bulk-export-unavailable = Das Storage-Backend hostet keinen Settings-Store; Exportaufträge können nicht verfolgt werden. +bulk-export-scope = Was möchten Sie exportieren? +bulk-export-scope-system = Alles +bulk-export-scope-system-hint = Der gesamte Server — jeder unten ausgewählte Ressourcentyp. +bulk-export-scope-patient = Patienten +bulk-export-scope-patient-hint = Jeder Patient und die zugehörigen Datensätze. Nichts Patientenfremdes. +bulk-export-scope-group = Gruppe +bulk-export-scope-group-hint = Nur die Mitglieder einer bereits definierten Kohorte. +bulk-export-field-group-id = Gruppen-ID +bulk-export-field-group-id-hint = Erforderlich für den Gruppen-Umfang: die ID der zu exportierenden FHIR-Group. +bulk-export-field-name = Name +bulk-export-field-name-placeholder = Diabetes-Register 2024 +bulk-export-types = Ressourcentypen +bulk-export-types-hint = Nichts ankreuzen, um alle Typen zu exportieren. +bulk-export-narrow = Eingrenzen +bulk-export-field-elements = FHIR-Elemente +bulk-export-field-type-filter = Typfilter +bulk-export-field-since = Seit +bulk-export-since-all = Gesamter Zeitraum +bulk-export-since-day = Letzter Tag +bulk-export-since-week = Letzte 7 Tage +bulk-export-since-month = Letzte 4 Wochen +bulk-export-since-custom = Benutzerdefiniert +bulk-export-field-since-custom = Benutzerdefinierter Zeitpunkt +bulk-export-field-since-custom-hint = Gilt, wenn Seit auf Benutzerdefiniert steht. RFC 3339, z. B. 2026-08-01T00:00:00Z. +bulk-export-start = Export starten +bulk-export-running = laufend +bulk-export-none = Noch keine Exporte. Starten Sie einen auf der Massenexport-Seite. +bulk-export-status-in-progress = Läuft +bulk-export-status-complete = Abgeschlossen +bulk-export-status-failed = Fehlgeschlagen +bulk-export-status-cancelled = Abgebrochen +bulk-export-progress = Fortschritt +bulk-export-progress-waiting = Warten auf den ersten Statusbericht … +bulk-export-files = Dateien +bulk-export-finished-in = fertig in +bulk-export-error = Fehler +bulk-export-cancel = Abbrechen +bulk-export-retry = Erneut versuchen diff --git a/locales/en/main.ftl b/locales/en/main.ftl index 2bbbb80ef..0b22bf280 100644 --- a/locales/en/main.ftl +++ b/locales/en/main.ftl @@ -599,3 +599,49 @@ editor-hint-date = FHIR date: YYYY, YYYY-MM, or YYYY-MM-DD editor-hint-datetime = FHIR dateTime: YYYY, YYYY-MM, YYYY-MM-DD, or a full timestamp with timezone (2024-05-17T14:30:00+02:00) editor-hint-time = FHIR time: HH:MM:SS editor-hint-instant = FHIR instant: full timestamp with timezone, e.g. 2024-05-17T14:30:00.000Z + +## Bulk Export workspace (#537) + +bulk-export-title = Bulk Export +bulk-export-active-title = Active Exports +bulk-export-active-link = Active exports +bulk-export-new = New export +bulk-export-unavailable = The storage backend does not host the settings store, so export jobs cannot be tracked. +bulk-export-scope = What are you exporting? +bulk-export-scope-system = Everything +bulk-export-scope-system-hint = The whole server — every resource type you select below. +bulk-export-scope-patient = Patients +bulk-export-scope-patient-hint = Every patient and the records that belong to them. Nothing patient-unrelated. +bulk-export-scope-group = Group +bulk-export-scope-group-hint = Just the members of a cohort you've already defined. +bulk-export-field-group-id = Group ID +bulk-export-field-group-id-hint = Required for the Group scope: the id of the FHIR Group to export. +bulk-export-field-name = Name +bulk-export-field-name-placeholder = Diabetes registry 2024 +bulk-export-types = Resource types +bulk-export-types-hint = Leave everything unchecked to export every type. +bulk-export-narrow = Narrow it down +bulk-export-field-elements = FHIR elements +bulk-export-field-type-filter = Type filter +bulk-export-field-since = Since +bulk-export-since-all = All time +bulk-export-since-day = Last day +bulk-export-since-week = Last 7 days +bulk-export-since-month = Last 4 weeks +bulk-export-since-custom = Custom +bulk-export-field-since-custom = Custom instant +bulk-export-field-since-custom-hint = Used when Since is Custom. RFC 3339, e.g. 2026-08-01T00:00:00Z. +bulk-export-start = Start export +bulk-export-running = running +bulk-export-none = No exports yet. Start one from the Bulk Export page. +bulk-export-status-in-progress = In progress +bulk-export-status-complete = Complete +bulk-export-status-failed = Failed +bulk-export-status-cancelled = Cancelled +bulk-export-progress = Progress +bulk-export-progress-waiting = Waiting for the first status report… +bulk-export-files = Files +bulk-export-finished-in = finished in +bulk-export-error = Error +bulk-export-cancel = Cancel +bulk-export-retry = Retry diff --git a/locales/es/main.ftl b/locales/es/main.ftl index ac25b476f..df5ab18e9 100644 --- a/locales/es/main.ftl +++ b/locales/es/main.ftl @@ -596,3 +596,49 @@ editor-hint-date = FHIR date: YYYY, YYYY-MM o YYYY-MM-DD editor-hint-datetime = FHIR dateTime: YYYY, YYYY-MM, YYYY-MM-DD o un timestamp completo con zona horaria (2024-05-17T14:30:00+02:00) editor-hint-time = FHIR time: HH:MM:SS editor-hint-instant = FHIR instant: timestamp completo con zona horaria, p. ej. 2024-05-17T14:30:00.000Z + +## Bulk Export workspace (#537) + +bulk-export-title = Exportación masiva +bulk-export-active-title = Exportaciones activas +bulk-export-active-link = Exportaciones activas +bulk-export-new = Nueva exportación +bulk-export-unavailable = El backend de almacenamiento no aloja el settings store; no se pueden rastrear los trabajos de exportación. +bulk-export-scope = ¿Qué desea exportar? +bulk-export-scope-system = Todo +bulk-export-scope-system-hint = El servidor completo — cada tipo de recurso que seleccione abajo. +bulk-export-scope-patient = Pacientes +bulk-export-scope-patient-hint = Cada paciente y los registros que le pertenecen. Nada ajeno a pacientes. +bulk-export-scope-group = Grupo +bulk-export-scope-group-hint = Solo los miembros de una cohorte ya definida. +bulk-export-field-group-id = ID del grupo +bulk-export-field-group-id-hint = Requerido para el alcance Grupo: el id del Group FHIR a exportar. +bulk-export-field-name = Nombre +bulk-export-field-name-placeholder = Registro de diabetes 2024 +bulk-export-types = Tipos de recurso +bulk-export-types-hint = Deje todo sin marcar para exportar todos los tipos. +bulk-export-narrow = Acotar +bulk-export-field-elements = Elementos FHIR +bulk-export-field-type-filter = Filtro por tipo +bulk-export-field-since = Desde +bulk-export-since-all = Todo el tiempo +bulk-export-since-day = Último día +bulk-export-since-week = Últimos 7 días +bulk-export-since-month = Últimas 4 semanas +bulk-export-since-custom = Personalizado +bulk-export-field-since-custom = Instante personalizado +bulk-export-field-since-custom-hint = Se usa cuando Desde es Personalizado. RFC 3339, p. ej. 2026-08-01T00:00:00Z. +bulk-export-start = Iniciar exportación +bulk-export-running = en curso +bulk-export-none = Aún no hay exportaciones. Inicie una desde la página de Exportación masiva. +bulk-export-status-in-progress = En curso +bulk-export-status-complete = Completada +bulk-export-status-failed = Fallida +bulk-export-status-cancelled = Cancelada +bulk-export-progress = Progreso +bulk-export-progress-waiting = Esperando el primer reporte de estado… +bulk-export-files = Archivos +bulk-export-finished-in = terminada en +bulk-export-error = Error +bulk-export-cancel = Cancelar +bulk-export-retry = Reintentar