feature: Self-Service Clinical Testing Sessions
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A production-quality clinical backend built with ASP.NET Core 8, PostgreSQL, Apache Kafka, RabbitMQ, Elasticsearch, Redis, and MinIO. The domain models the observe-alert-acknowledge lifecycle at the center of any clinical monitoring system: patient encounters, continuous vital sign and lab result ingest, real-time sepsis and NEWS2 scoring, and clinician notification with automatic escalation.
|
A production-quality clinical backend built with ASP.NET Core 8, PostgreSQL, Apache Kafka, RabbitMQ, Elasticsearch, Redis, and MinIO. The domain models the observe-alert-acknowledge lifecycle at the center of any clinical monitoring system: patient encounters, continuous vital sign and lab result ingest, real-time sepsis and NEWS2 scoring, and clinician notification with automatic escalation.
|
||||||
|
|
||||||
**Implementation status:** Thirty-three planned phases are complete through Phase 35 (plus Phases 20–23) — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, the MinIO Parquet data lake, clinical data model expansion, warning alerts and orders, the NEWS2 composite scoring engine, trend detection with alert suppression, qSOFA bedside screening, medication administration with alert correlation annotations, the console replay simulator, the **Vue 3 ward dashboard**, clinician feedback mode, **Glasgow Coma Scale (GCS) scoring**, **SOFA organ-dysfunction scoring with baseline tracking and delta sepsis alerts**, the **Sepsis-3 clinical refactor** (SIRS removed, qSOFA repositioned as screening, SOFA delta ≥ 2 triggers bundles), **frontend GCS entry and SOFA display**, **expanded simulator scenarios with clinical validation**, the **Site & Gateway Registry** with dual authentication, shared clinical sync contracts, and fleet health Prometheus gauges, the **Ward Gateway Service** (local-first clinical path with offline buffering and central sync), the **FHIR R4 Inbound Facade** for EHR integration, **Role-Based Access Control (RBAC) with clinical audit logging**, the **Dashboard Gap Analysis Fixes** (SOFA/GCS/qSOFA history charts, patient banner, encounter timeline, medication markers on vital charts), the **Enhanced Dashboard** (department overview, sepsis bundle board, critical alert notifications, shift handoff reports, vitals entry form, sortable/filterable ward table), **Degraded Operations Visibility** (gateway fleet operations panel, stale gateway auto-detection, discharge summary API, admin panels for user/threshold/audit/reconciliation management, degraded-mode banner), **Alert Quality Analytics** (server-side clinician feedback with `AlertFeedback` entity, `AlertQualityAggregatorService` background metrics, quality metrics API, Grafana alert quality dashboard), **Explainable Alerts** (immutable JSONB `explanation` on composite alerts with score contributors, trend context, structured medication context, and bedside `NarrativeSummary`; `AlertResponse` DTO on GET/list/acknowledge/resolve; dashboard `AlertReasoning.vue`; ES indexer and data lake propagation; ward gateway sync), and **MIMIC-IV Replay Scenario Generator** (offline CLI tool converting real de-identified ICU data from MIT PhysioNet into VigilCare scenario JSONs; streaming CSV parser for 668K-row chartevents; 17 chart + 8 lab item ID mappings to VigilCare observation codes; GCS text-to-numeric conversion; Fahrenheit-to-Celsius; blood pressure deduplication preferring non-invasive over arterial; 10-observation cluster limit enforcement; `mimic-list` and `mimic-generate` CLI commands with Spectre.Console output; 100 patients / 140 ICU stays available for replay through NEWS2, SOFA, GCS, qSOFA, trend detection, and alerting). Post-phase hardening includes health check endpoints, Kafka poison pill protection, outbox dead-letter with retry tracking, data lake partial-commit safety, MRN sequence-based generation, FHIR bundle transaction rollback, **FHIR R4 read/search endpoints** (Patient and Encounter), **alert threshold deletion with audit trail**, **FHIR API key rotation** (constant-time multi-key validation), **authorization failure logging** with Prometheus metrics, **JWT signing key validation** at startup, **token refresh and revocation** (short-lived access tokens with rotating refresh tokens, server-side logout, proactive frontend refresh), and **concurrency hardening** (transactional sepsis bundle creation, unique active encounter constraint). See [Implemented Phases](#implemented-phases) for the full breakdown. Guides: [dashboard-guide.md](docs/dashboard-guide.md) (technical), [clinical-testing-guide.md](docs/clinical-testing-guide.md) (doctors & nurses).
|
**Implementation status:** Phases **1–38** are complete — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, the MinIO Parquet data lake, clinical data model expansion, warning alerts and orders, the NEWS2 composite scoring engine, trend detection with alert suppression, qSOFA bedside screening, medication administration with alert correlation annotations, the console replay simulator, the **Vue 3 ward dashboard**, clinician feedback mode, **Glasgow Coma Scale (GCS) scoring**, **SOFA organ-dysfunction scoring with baseline tracking and delta sepsis alerts**, the **Sepsis-3 clinical refactor** (SIRS removed, qSOFA repositioned as screening, SOFA delta ≥ 2 triggers bundles), **frontend GCS entry and SOFA display**, **expanded simulator scenarios with clinical validation**, the **Site & Gateway Registry** with dual authentication, shared clinical sync contracts, and fleet health Prometheus gauges, the **Ward Gateway Service** (local-first clinical path with offline buffering and central sync), the **FHIR R4 Inbound Facade** for EHR integration, **Role-Based Access Control (RBAC) with clinical audit logging**, the **Dashboard Gap Analysis Fixes** (SOFA/GCS/qSOFA history charts, patient banner, encounter timeline, medication markers on vital charts), the **Enhanced Dashboard** (department overview, sepsis bundle board, critical alert notifications, shift handoff reports, vitals entry form, sortable/filterable ward table), **Degraded Operations Visibility** (gateway fleet operations panel, stale gateway auto-detection, discharge summary API, admin panels for user/threshold/audit/reconciliation management, degraded-mode banner), **Alert Quality Analytics** (server-side clinician feedback with `AlertFeedback` entity, `AlertQualityAggregatorService` background metrics, quality metrics API, Grafana alert quality dashboard, **scenario-attributed feedback** for simulated alerts), **Explainable Alerts** (immutable JSONB `explanation` on composite alerts with score contributors, trend context, structured medication context, and bedside `NarrativeSummary`; `AlertResponse` DTO on GET/list/acknowledge/resolve; dashboard `AlertReasoning.vue`; ES indexer and data lake propagation; ward gateway sync), **MIMIC-IV Replay Scenario Generator** (offline CLI tool converting real de-identified ICU data from MIT PhysioNet into VigilCare scenario JSONs; streaming CSV parser for 668K-row chartevents; 17 chart + 8 lab item ID mappings to VigilCare observation codes; GCS text-to-numeric conversion; Fahrenheit-to-Celsius; blood pressure deduplication preferring non-invasive over arterial; 10-observation cluster limit enforcement; `mimic-list` and `mimic-generate` CLI commands with Spectre.Console output; 100 patients / 140 ICU stays available for replay through NEWS2, SOFA, GCS, qSOFA, trend detection, and alerting), and **self-service clinical simulation** (Phases 36–38: default-off in-app runner, Simulation control UI, session presets A–D, ward purge/reset, and a rewritten clinical testing guide so clinicians complete sessions with no terminal). Post-phase hardening includes health check endpoints, Kafka poison pill protection, outbox dead-letter with retry tracking, data lake partial-commit safety, MRN sequence-based generation, FHIR bundle transaction rollback, **FHIR R4 read/search endpoints** (Patient and Encounter), **alert threshold deletion with audit trail**, **FHIR API key rotation** (constant-time multi-key validation), **authorization failure logging** with Prometheus metrics, **JWT signing key validation** at startup, **token refresh and revocation** (short-lived access tokens with rotating refresh tokens, server-side logout, proactive frontend refresh), and **concurrency hardening** (transactional sepsis bundle creation, unique active encounter constraint). See [Implemented Phases](#implemented-phases) for the full breakdown. Guides: [dashboard-guide.md](docs/dashboard-guide.md) (technical), [clinical-testing-guide.md](docs/clinical-testing-guide.md) (doctors & nurses — self-service Simulation sessions), [simulator-guide.md](docs/simulator-guide.md) (CLI / CI), [vigilcare-clinical-roadmap.md](docs/plans/vigilcare-clinical-roadmap.md) (Phases 36–38 order).
|
||||||
|
|
||||||
## Domain Model — How It Maps to a Real Clinical System
|
## Domain Model — How It Maps to a Real Clinical System
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
|
|||||||
- **Medication Administration** — `POST /encounters/:id/medications` records drug administrations (name, dose, route, timestamp, administered-by); `GET /encounters/:id/medications` lists with optional `since` filter; `GET /medications/:id` detail; active-encounter guard; FluentValidation on request DTOs
|
- **Medication Administration** — `POST /encounters/:id/medications` records drug administrations (name, dose, route, timestamp, administered-by); `GET /encounters/:id/medications` lists with optional `since` filter; `GET /medications/:id` detail; active-encounter guard; FluentValidation on request DTOs
|
||||||
- **Medication Correlation Annotations** — `MedicationCorrelationHelper` appends medication context to warning alert `details` when a mapped drug was administered within the correlation window (default 90 min); explainable alerts (NEWS2, SOFA, GCS, rapid deterioration) receive structured `MedicationContext` in the JSONB `explanation` via `TryGetContextAsync()`; drug-to-vital mappings in `MedicationCorrelation` config (`appsettings.json`); annotates rather than suppresses — alerts still fire; sepsis, trend, and critical sync-path alerts are never annotated; design rationale in `docs/decisions/medication-correlation-design.md`
|
- **Medication Correlation Annotations** — `MedicationCorrelationHelper` appends medication context to warning alert `details` when a mapped drug was administered within the correlation window (default 90 min); explainable alerts (NEWS2, SOFA, GCS, rapid deterioration) receive structured `MedicationContext` in the JSONB `explanation` via `TryGetContextAsync()`; drug-to-vital mappings in `MedicationCorrelation` config (`appsettings.json`); annotates rather than suppresses — alerts still fire; sepsis, trend, and critical sync-path alerts are never annotated; design rationale in `docs/decisions/medication-correlation-design.md`
|
||||||
- **Ward Dashboard APIs** — `GET /encounters` returns paginated `WardEncounterSummary` rows (patient name/MRN, room/bed, department, status, latest NEWS2 score, live qSOFA criteria count from Redis, sepsis bundle status, open alert count, SOFA score/delta, GCS score/classification, attending physician, admitted-at, last observation time); filterable by `status` and `department`; `GET /encounters/:id/qsofa/current` exposes Redis-backed qSOFA state; `GET /sepsis-bundles` lists bundles hospital-wide with optional `status` filter (returns `SepsisBundleSummary` with patient demographics, elements, and deadlines); CORS policy `Dashboard` allows configured origins (default `http://localhost:5173`)
|
- **Ward Dashboard APIs** — `GET /encounters` returns paginated `WardEncounterSummary` rows (patient name/MRN, room/bed, department, status, latest NEWS2 score, live qSOFA criteria count from Redis, sepsis bundle status, open alert count, SOFA score/delta, GCS score/classification, attending physician, admitted-at, last observation time); filterable by `status` and `department`; `GET /encounters/:id/qsofa/current` exposes Redis-backed qSOFA state; `GET /sepsis-bundles` lists bundles hospital-wide with optional `status` filter (returns `SepsisBundleSummary` with patient demographics, elements, and deadlines); CORS policy `Dashboard` allows configured origins (default `http://localhost:5173`)
|
||||||
- **Ward Dashboard Frontend** — Vue 3 SPA (`vigilcare-dashboard/`) with virtual ward table (multi-column sortable, patient search, quick-filters for critical/alerts/sepsis), patient detail (vitals, scores, alerts, orders, sepsis bundle, GCS entry form, SOFA score panel, patient banner with demographics/allergies/emergency contact, encounter timeline, vitals entry form for manual observation recording, discharge summary panel), alert center (global acknowledge/resolve with role-aware modal and acknowledgment note preview), department overview (unit-level snapshot cards with acuity bars, patient/alert/bundle counts per department), sepsis bundle board (real-time countdown timers, on-track/at-risk/overdue urgency sorting), critical alert banner with browser notifications and audible tone, shift handoff report generator (SBAR format with ward summary, exportable via print/PDF), vital sign trend charts with medication administration markers and local replay scrubbing, NEWS2 history chart, SOFA history chart with organ-system breakdown, GCS history chart with component tracking, qSOFA evaluation history, structured alert reasoning (`AlertReasoning.vue` — score contributors, trend context, medication context, narrative summary from `explanation`), clinician feedback on every alert, admin panels (threshold management, user management, audit log viewer, reconciliation viewer), gateway operations dashboard with degraded-mode banner, and alert quality analytics with quality charts; role-aware sidebar navigation; polls API every 5–10 s; guides in `docs/dashboard-guide.md` and `docs/clinical-testing-guide.md`
|
- **Ward Dashboard Frontend** — Vue 3 SPA (`vigilcare-dashboard/`) with virtual ward table (multi-column sortable, patient search, quick-filters for critical/alerts/sepsis, **SIM badge** on simulated patients), patient detail (vitals, scores, alerts, orders, sepsis bundle, GCS entry form, SOFA score panel, patient banner with demographics/allergies/emergency contact, encounter timeline, vitals entry form for manual observation recording, discharge summary panel), alert center (global acknowledge/resolve with role-aware modal and acknowledgment note preview), department overview (unit-level snapshot cards with acuity bars, patient/alert/bundle counts per department), sepsis bundle board (real-time countdown timers, on-track/at-risk/overdue urgency sorting), critical alert banner with browser notifications and audible tone, shift handoff report generator (SBAR format with ward summary, exportable via print/PDF), vital sign trend charts with medication administration markers and local replay scrubbing, NEWS2 history chart, SOFA history chart with organ-system breakdown, GCS history chart with component tracking, qSOFA evaluation history, structured alert reasoning (`AlertReasoning.vue` — score contributors, trend context, medication context, narrative summary from `explanation`), clinician feedback on every alert, **Simulation control** (`SimulationControlView` — Sessions / Scenarios tabs, speed control, run progress, typed ward reset), admin panels (threshold management, user management, audit log viewer, reconciliation viewer), gateway operations dashboard with degraded-mode banner, and alert quality analytics with quality charts **and by-scenario breakdown**; role-aware sidebar navigation; polls API every 5–10 s; guides in `docs/dashboard-guide.md` and `docs/clinical-testing-guide.md`
|
||||||
- **FHIR R4 Inbound Facade** — `POST /fhir/R4/{Patient,Encounter,Observation,MedicationAdministration}` accepts FHIR R4 JSON resources (`application/fhir+json`); `POST /fhir/R4` processes transaction Bundles (Patient → Encounter → Observation in dependency order); `GET /fhir/R4/metadata` returns a CapabilityStatement; LOINC-to-internal code mapping (19 observation codes + SNOMED CT fallbacks); Fahrenheit-to-Celsius unit conversion; `ExternalResourceIdentifier` table links hospital MRNs and visit numbers to internal UUIDs for idempotent upserts; `FhirApiKeyOrJwtMiddleware` authenticates via JWT bearer or `X-Api-Key` header (supports multiple keys via `Fhir:ApiKeys` array for zero-downtime rotation; constant-time comparison via `CryptographicOperations.FixedTimeEquals`); `FhirExceptionFilter` returns FHIR `OperationOutcome` on errors; configurable identifier systems, department codes, and encounter class mappings via `Fhir` config section; Prometheus `fhir_ingest_total` and `fhir_mapping_errors_total`; integration guide for Mirth Connect HL7v2→FHIR channels in `docs/integration/mirth-fhir-channels.md`
|
- **FHIR R4 Inbound Facade** — `POST /fhir/R4/{Patient,Encounter,Observation,MedicationAdministration}` accepts FHIR R4 JSON resources (`application/fhir+json`); `POST /fhir/R4` processes transaction Bundles (Patient → Encounter → Observation in dependency order); `GET /fhir/R4/metadata` returns a CapabilityStatement; LOINC-to-internal code mapping (19 observation codes + SNOMED CT fallbacks); Fahrenheit-to-Celsius unit conversion; `ExternalResourceIdentifier` table links hospital MRNs and visit numbers to internal UUIDs for idempotent upserts; `FhirApiKeyOrJwtMiddleware` authenticates via JWT bearer or `X-Api-Key` header (supports multiple keys via `Fhir:ApiKeys` array for zero-downtime rotation; constant-time comparison via `CryptographicOperations.FixedTimeEquals`); `FhirExceptionFilter` returns FHIR `OperationOutcome` on errors; configurable identifier systems, department codes, and encounter class mappings via `Fhir` config section; Prometheus `fhir_ingest_total` and `fhir_mapping_errors_total`; integration guide for Mirth Connect HL7v2→FHIR channels in `docs/integration/mirth-fhir-channels.md`
|
||||||
- **FHIR R4 Read/Search** — `GET /fhir/R4/Patient/{id}` reads a Patient by internal ID; `GET /fhir/R4/Patient` searches by `identifier` (system|value) or lists all patients; `GET /fhir/R4/Encounter/{id}` reads an Encounter by internal ID; `GET /fhir/R4/Encounter` searches by `patient` (UUID) and/or `status` (`in-progress`, `finished`, `cancelled`); all return FHIR R4 JSON (`application/fhir+json`); search endpoints return `Bundle.type=searchset`; requires `fhir:read` permission (Admin and Integration roles); internal resources mapped back to FHIR via `PatientFhirMapper.ToFhirResponse` / `EncounterFhirMapper.ToFhirResponse` with hospital identifier resolution; Prometheus `fhir_read_total` counter with `resource_type`, `interaction`, `outcome` labels
|
- **FHIR R4 Read/Search** — `GET /fhir/R4/Patient/{id}` reads a Patient by internal ID; `GET /fhir/R4/Patient` searches by `identifier` (system|value) or lists all patients; `GET /fhir/R4/Encounter/{id}` reads an Encounter by internal ID; `GET /fhir/R4/Encounter` searches by `patient` (UUID) and/or `status` (`in-progress`, `finished`, `cancelled`); all return FHIR R4 JSON (`application/fhir+json`); search endpoints return `Bundle.type=searchset`; requires `fhir:read` permission (Admin and Integration roles); internal resources mapped back to FHIR via `PatientFhirMapper.ToFhirResponse` / `EncounterFhirMapper.ToFhirResponse` with hospital identifier resolution; Prometheus `fhir_read_total` counter with `resource_type`, `interaction`, `outcome` labels
|
||||||
- **Role-Based Access Control (RBAC)** — JWT bearer authentication (`POST /auth/login`); four clinical roles (`Nurse`, `Physician`, `Admin`, `Integration`) with 18 granular permissions (`patients:read`, `alerts:acknowledge`, `alerts:feedback`, `thresholds:write`, `fhir:ingest`, `fhir:read`, `audit:read`, etc.); `AuthorizePermission` attribute on every controller action; `PermissionAuthorizationHandler` resolves role → permission at runtime from `ClinicalRolePermissionMap` and logs authorization failures with structured details (user, role, permission, endpoint) plus `authorization_failures_total` Prometheus counter; `CurrentUserService` exposes authenticated identity (user ID, display name, role, IP address) to services; nurses and physicians get clinical read/write permissions; admins additionally get `thresholds:write`, `fhir:read`, `audit:read`, and `users:admin`; integration accounts get FHIR ingest and read access; FHIR endpoints accept both JWT and `X-Api-Key` authentication via `FhirApiKeyOrJwtMiddleware`; alert `acknowledgedBy` is set from the authenticated user identity, not the request body; startup validates JWT signing key is at least 256 bits (HMAC-SHA256 minimum); **token refresh and revocation** — short-lived access tokens (15 min) paired with rotating opaque refresh tokens (7 days) stored in the `refresh_tokens` table; `POST /auth/refresh` exchanges a valid refresh token for a new access + refresh token pair (rotation on every use revokes the previous token); `POST /auth/logout` revokes the refresh token server-side with `USER_LOGOUT` audit log; frontend auto-refreshes 1 minute before expiry, retries on 401, and redirects to login when the refresh token is exhausted; logout button in header, sidebar, and mobile nav; four seeded demo users (`nurse.demo`, `physician.demo`, `admin.demo`, `integration.mirth`)
|
- **Role-Based Access Control (RBAC)** — JWT bearer authentication (`POST /auth/login`); four clinical roles (`Nurse`, `Physician`, `Admin`, `Integration`) with 18 granular permissions (`patients:read`, `alerts:acknowledge`, `alerts:feedback`, `thresholds:write`, `fhir:ingest`, `fhir:read`, `audit:read`, etc.); `AuthorizePermission` attribute on every controller action; `PermissionAuthorizationHandler` resolves role → permission at runtime from `ClinicalRolePermissionMap` and logs authorization failures with structured details (user, role, permission, endpoint) plus `authorization_failures_total` Prometheus counter; `CurrentUserService` exposes authenticated identity (user ID, display name, role, IP address) to services; nurses and physicians get clinical read/write permissions; admins additionally get `thresholds:write`, `fhir:read`, `audit:read`, and `users:admin`; integration accounts get FHIR ingest and read access; FHIR endpoints accept both JWT and `X-Api-Key` authentication via `FhirApiKeyOrJwtMiddleware`; alert `acknowledgedBy` is set from the authenticated user identity, not the request body; startup validates JWT signing key is at least 256 bits (HMAC-SHA256 minimum); **token refresh and revocation** — short-lived access tokens (15 min) paired with rotating opaque refresh tokens (7 days) stored in the `refresh_tokens` table; `POST /auth/refresh` exchanges a valid refresh token for a new access + refresh token pair (rotation on every use revokes the previous token); `POST /auth/logout` revokes the refresh token server-side with `USER_LOGOUT` audit log; frontend auto-refreshes 1 minute before expiry, retries on 401, and redirects to login when the refresh token is exhausted; logout button in header, sidebar, and mobile nav; four seeded demo users (`nurse.demo`, `physician.demo`, `admin.demo`, `integration.mirth`)
|
||||||
@@ -87,12 +87,13 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
|
|||||||
- **MRN Sequence Generation** — MRN numbers are generated via a PostgreSQL sequence (`mrn_seq`) instead of MAX+1 queries; eliminates race conditions under concurrent patient registration; configurable prefix and digit count via `PatientOptions`
|
- **MRN Sequence Generation** — MRN numbers are generated via a PostgreSQL sequence (`mrn_seq`) instead of MAX+1 queries; eliminates race conditions under concurrent patient registration; configurable prefix and digit count via `PatientOptions`
|
||||||
- **FHIR Bundle Transaction Rollback** — `FhirBundleProcessor` wraps all bundle entry processing in a database transaction; on any entry failure, the transaction is rolled back and the response includes the `OperationOutcome` for the failed entry; prevents partial state from orphaned Patient/Encounter records
|
- **FHIR Bundle Transaction Rollback** — `FhirBundleProcessor` wraps all bundle entry processing in a database transaction; on any entry failure, the transaction is rolled back and the response includes the `OperationOutcome` for the failed entry; prevents partial state from orphaned Patient/Encounter records
|
||||||
- **Clinician Feedback Mode** — six quick ratings per alert (useful, too early, too late, false positive, missing context, would act); optional notes; server-side `AlertFeedback` entity persisted per user per alert (`POST /alerts/{id}/feedback`); `alerts:feedback` permission for Nurse, Physician, and Admin roles; Feedback Summary with aggregate stats and JSON/CSV export; client-side persistence for product research
|
- **Clinician Feedback Mode** — six quick ratings per alert (useful, too early, too late, false positive, missing context, would act); optional notes; server-side `AlertFeedback` entity persisted per user per alert (`POST /alerts/{id}/feedback`); `alerts:feedback` permission for Nurse, Physician, and Admin roles; Feedback Summary with aggregate stats and JSON/CSV export; client-side persistence for product research
|
||||||
- **Alert Quality Analytics** — `AlertQualityAggregatorService` periodically computes per-alert-type quality metrics (acknowledgement rate, false positive rate, useful rate, would-act rate, avg seconds to acknowledge/resolve) into `alert_quality_metrics` table; `AlertQualityMetricsController` exposes `GET /alerts/quality-metrics` (time-range filterable, optional alert type) and `GET /alerts/quality-metrics/summary`; Grafana alert quality dashboard (`infra/grafana/dashboards/alert-quality-dashboard.json`); frontend `AlertQualityAnalytics.vue` with `AlertQualityChart.vue`; Prometheus `alert_quality_useful_rate` and `alert_quality_false_positive_rate` gauges
|
- **Alert Quality Analytics** — `AlertQualityAggregatorService` periodically computes per-alert-type quality metrics (acknowledgement rate, false positive rate, useful rate, would-act rate, avg seconds to acknowledge/resolve) into `alert_quality_metrics` table; `AlertQualityMetricsController` exposes `GET /alerts/quality-metrics` (time-range filterable, optional alert type), `GET /alerts/quality-metrics/summary`, and `GET /alerts/quality-metrics/feedback` (per-alert feedback rows with optional `?scenarioId=` filter and `scenarioId`/`sessionId` attribution when simulation is enabled); Grafana alert quality dashboard (`infra/grafana/dashboards/alert-quality-dashboard.json`); frontend `AlertQualityAnalytics.vue` with `AlertQualityChart.vue`, scenario filter, by-scenario breakdown, and CSV export including attribution columns; Prometheus `alert_quality_useful_rate` and `alert_quality_false_positive_rate` gauges
|
||||||
- **Explainable Alerts** — `AlertExplanation` value object (`ScoreContributor`, `TrendContext`, `MedicationContext`, `NarrativeSummary`) serialized as JSONB on `ClinicalAlert.Explanation` at creation time; `AlertExplanationBuilder` and contributor builders (NEWS2, SOFA, GCS, trend) assemble explanation from scoring outputs; `ClinicalAlertFactory` idempotent INSERT with explanation; NEWS2, SOFA, GCS, and `TrendDetector` wire explanation and include `explanation` in `alert.generated` outbox payloads; `AlertResponse` DTO exposes optional `Explanation` on GET/list/acknowledge/resolve; Elasticsearch indexes `NarrativeSummary`; data lake Parquet includes `explanation_json`; ward gateway `LocalClinicalAlert.ExplanationJson` synced via `ClinicalSyncBatchProcessor`; dashboard `AlertReasoning.vue` + `alertExplanation.js` composable render structured reasoning; simulator `ExpectedOutcomeValidator` supports `narrativeContains` on key scenarios; `ExplainableAlertsTests` (10 tests) + `run-phase34-verification.sh`
|
- **Explainable Alerts** — `AlertExplanation` value object (`ScoreContributor`, `TrendContext`, `MedicationContext`, `NarrativeSummary`) serialized as JSONB on `ClinicalAlert.Explanation` at creation time; `AlertExplanationBuilder` and contributor builders (NEWS2, SOFA, GCS, trend) assemble explanation from scoring outputs; `ClinicalAlertFactory` idempotent INSERT with explanation; NEWS2, SOFA, GCS, and `TrendDetector` wire explanation and include `explanation` in `alert.generated` outbox payloads; `AlertResponse` DTO exposes optional `Explanation` on GET/list/acknowledge/resolve; Elasticsearch indexes `NarrativeSummary`; data lake Parquet includes `explanation_json`; ward gateway `LocalClinicalAlert.ExplanationJson` synced via `ClinicalSyncBatchProcessor`; dashboard `AlertReasoning.vue` + `alertExplanation.js` composable render structured reasoning; simulator `ExpectedOutcomeValidator` supports `narrativeContains` on key scenarios; `ExplainableAlertsTests` (10 tests) + `run-phase34-verification.sh`
|
||||||
- **Degraded Operations Visibility** — `GatewayStaleDetectorService` auto-marks gateways OFFLINE when heartbeat exceeds configurable `StaleThresholdMinutes`; `OperationsController` (`GET /operations/gateways`, `GET /operations/gateways/{id}`, `GET /operations/sites/{siteId}/summary`) provides fleet management API; `DischargeSummaryService` with `GET /encounters/{id}/discharge-summary` (info) and `GET /encounters/{id}/discharge-summary/content` (MinIO PDF download); `DischargeSummaryPanel.vue` on patient detail; `DegradedModeBanner.vue` warns when gateways are offline; `GatewayOperations.vue` operations dashboard
|
- **Degraded Operations Visibility** — `GatewayStaleDetectorService` auto-marks gateways OFFLINE when heartbeat exceeds configurable `StaleThresholdMinutes`; `OperationsController` (`GET /operations/gateways`, `GET /operations/gateways/{id}`, `GET /operations/sites/{siteId}/summary`) provides fleet management API; `DischargeSummaryService` with `GET /encounters/{id}/discharge-summary` (info) and `GET /encounters/{id}/discharge-summary/content` (MinIO PDF download); `DischargeSummaryPanel.vue` on patient detail; `DegradedModeBanner.vue` warns when gateways are offline; `GatewayOperations.vue` operations dashboard
|
||||||
- **User Management** — `UsersController` (`GET /users`, `POST /users`, `PATCH /users/{id}`) for admin user account CRUD; `UserService` with BCrypt password hashing; `UserManagementView.vue` with `UserFormModal.vue` (create/edit users, role assignment, active toggle)
|
- **User Management** — `UsersController` (`GET /users`, `POST /users`, `PATCH /users/{id}`) for admin user account CRUD; `UserService` with BCrypt password hashing; `UserManagementView.vue` with `UserFormModal.vue` (create/edit users, role assignment, active toggle)
|
||||||
- **Admin Dashboard Panels** — `ThresholdManagementView.vue` with `ThresholdFormModal.vue` (create/edit alert thresholds); `AuditLogView.vue` (filterable audit log viewer with action/entity/user/date filters); `ReconciliationView.vue` (safety finding viewer); sidebar navigation with role-aware admin section; `CollapsibleSection.vue` and `SeverityBadge.vue` UI components
|
- **Admin Dashboard Panels** — `ThresholdManagementView.vue` with `ThresholdFormModal.vue` (create/edit alert thresholds); `AuditLogView.vue` (filterable audit log viewer with action/entity/user/date filters); `ReconciliationView.vue` (safety finding viewer); sidebar navigation with role-aware admin section; `CollapsibleSection.vue` and `SeverityBadge.vue` UI components
|
||||||
- **Console Replay Simulator** — standalone `VigilCare.Simulator` .NET console app replays JSON scenario files against the live API with configurable speed (`--speed 0` instant, `60` = 60× faster); commands: `replay`, `replay-all`, `validate`, `dry-run`, `mimic-list`, `mimic-generate`; optional `--poll` shows alerts, NEWS2, GCS, SOFA, and sepsis bundle state during replay; `--gateway` targets the ward gateway (`http://localhost:5081`) with `--encounter-id`, `--skip-setup`, and `--gateway-token`; `alert_ack` events poll for open alerts on central before acknowledging (handles async alert pipeline at `--speed 0`); `ExpectedOutcomeValidator` validates alert `narrativeContains` on key scenarios; twelve sample scenarios in `VigilCare.Simulator/Scenarios/List/` (including ward outage reconnect, GCS neurological decline, SOFA sepsis progression, and SpO₂/FiO₂ fallback); MIMIC-IV scenario generation from real ICU data; user guide in `docs/simulator-guide.md`
|
- **In-App Clinical Simulation (Phases 36–38)** — default-off `Simulation:Enabled` gate; `ScenarioCatalog` / `SessionCatalog` load scenario JSON and `sessions.json` presets; `SimulationRunner` hosted service replays via loopback API client; patients marked `IsSimulated`; `simulation_runs` with optional `SessionId`; REST under `/api/v1/simulation` (config, scenarios, sessions start, runs, data summary, purge); dashboard **Simulation** page (Sessions default tab, Scenarios catalogue, speed control, run panel, typed **Reset ward**); all-or-nothing multi-run session start with staggered launch; purge deletes only simulated patients and dependents and audits `SIMULATION_DATA_PURGED`; clinicians follow `docs/clinical-testing-guide.md` with no terminal
|
||||||
|
- **Console Replay Simulator** — standalone `VigilCare.Simulator` .NET console app (developer/CI tool — clinicians use the in-app Simulation page) replays JSON scenario files against the live API with configurable speed (`--speed 0` instant, `60` = 60× faster); commands: `replay`, `replay-all`, `validate`, `dry-run`, `mimic-list`, `mimic-generate`; optional `--poll` shows alerts, NEWS2, GCS, SOFA, and sepsis bundle state during replay; `--gateway` targets the ward gateway (`http://localhost:5081`) with `--encounter-id`, `--skip-setup`, and `--gateway-token`; `alert_ack` events poll for open alerts on central before acknowledging (handles async alert pipeline at `--speed 0`); `ExpectedOutcomeValidator` validates alert `narrativeContains` on key scenarios; twelve sample scenarios in `VigilCare.Simulator/Scenarios/List/` (including ward outage reconnect, GCS neurological decline, SOFA sepsis progression, and SpO₂/FiO₂ fallback); session presets in `VigilCare.Simulator/Scenarios/sessions.json`; MIMIC-IV scenario generation from real ICU data; user guide in `docs/simulator-guide.md`
|
||||||
- **MIMIC-IV Scenario Generator** — offline CLI tool that reads MIMIC-IV CSV files (`docs/MIMIC-IV/`, 100 patients, 140 ICU stays, 668K chart events, 107K lab events) and generates VigilCare scenario JSONs; `mimic-list <dir>` displays a Spectre.Console table of available stays with demographics, care unit, LOS, and outcome; `mimic-generate <dir> --stay-id <id>` produces a scenario with options for `--max-hours`, `--no-medications`, `--no-labs`, `--validate`; streaming CSV parser (`MimicCsvReader`) filters 668K-row chartevents by stay_id and item ID set at the string level before allocating records; `MimicItemMap` maps 17 chart event items (vitals, GCS, FiO₂, PaO₂, labs) and 8 lab event items to VigilCare observation codes; GCS text labels ("Obeys Commands" → 6, "To Speech" → 3) resolved from `valuenum` with text-to-numeric fallback dictionary; Fahrenheit temperature converted to Celsius; blood pressure deduplication prefers non-invasive (NBP) over arterial (ABP); 10-observation-per-cluster limit enforced by priority-based splitting (vitals first, labs spill to next offset); medications from prescriptions with dose parsing; generated scenarios pass `ScenarioValidator` and replay through the standard `replay` command
|
- **MIMIC-IV Scenario Generator** — offline CLI tool that reads MIMIC-IV CSV files (`docs/MIMIC-IV/`, 100 patients, 140 ICU stays, 668K chart events, 107K lab events) and generates VigilCare scenario JSONs; `mimic-list <dir>` displays a Spectre.Console table of available stays with demographics, care unit, LOS, and outcome; `mimic-generate <dir> --stay-id <id>` produces a scenario with options for `--max-hours`, `--no-medications`, `--no-labs`, `--validate`; streaming CSV parser (`MimicCsvReader`) filters 668K-row chartevents by stay_id and item ID set at the string level before allocating records; `MimicItemMap` maps 17 chart event items (vitals, GCS, FiO₂, PaO₂, labs) and 8 lab event items to VigilCare observation codes; GCS text labels ("Obeys Commands" → 6, "To Speech" → 3) resolved from `valuenum` with text-to-numeric fallback dictionary; Fahrenheit temperature converted to Celsius; blood pressure deduplication prefers non-invasive (NBP) over arterial (ABP); 10-observation-per-cluster limit enforced by priority-based splitting (vitals first, labs spill to next offset); medications from prescriptions with dose parsing; generated scenarios pass `ScenarioValidator` and replay through the standard `replay` command
|
||||||
- **RabbitMQ Notification Workers** — `NotificationPublisherService` reads `alert.generated` from Kafka and publishes paging jobs to `alerts.paging.queue`; `PagingWorkerService` sends the page and waits for acknowledgment; if no ack arrives before timeout it NACKs to `alerts.paging.dlq` with `x-message-ttl = 300000ms`; if the host is stopping, in-flight paging messages are NACKed with `requeue=true` so they are retried after restart and do not false-escalate; `EscalationWorkerService` pages the on-call backup and sets alert status to `escalated`; `DischargeSummaryWorkerService` reads `encounter.status.changed`, generates a discharge summary, and stores it in MinIO under `/discharge-summaries/{encounterId}/summary.pdf`
|
- **RabbitMQ Notification Workers** — `NotificationPublisherService` reads `alert.generated` from Kafka and publishes paging jobs to `alerts.paging.queue`; `PagingWorkerService` sends the page and waits for acknowledgment; if no ack arrives before timeout it NACKs to `alerts.paging.dlq` with `x-message-ttl = 300000ms`; if the host is stopping, in-flight paging messages are NACKed with `requeue=true` so they are retried after restart and do not false-escalate; `EscalationWorkerService` pages the on-call backup and sets alert status to `escalated`; `DischargeSummaryWorkerService` reads `encounter.status.changed`, generates a discharge summary, and stores it in MinIO under `/discharge-summaries/{encounterId}/summary.pdf`
|
||||||
- **Data Lake Writer** — `DataLakeWriterService` (consumer group `data-lake-writer`) buffers `observation.recorded`, `alert.generated`, and `encounter.status.changed` events, flushes date-partitioned Parquet files to MinIO (`/observations/`, `/alerts/`, `/encounters/`), and commits Kafka offsets only for topic-partitions where all uploads succeeded; failed partition buffers are retained in memory and retried on the next flush cycle (prevents data loss from partial upload failures); shutdown flush uses an uncanceled token so MinIO writes complete on Ctrl+C; `kafka_partition` and `kafka_offset` columns provide audit lineage
|
- **Data Lake Writer** — `DataLakeWriterService` (consumer group `data-lake-writer`) buffers `observation.recorded`, `alert.generated`, and `encounter.status.changed` events, flushes date-partitioned Parquet files to MinIO (`/observations/`, `/alerts/`, `/encounters/`), and commits Kafka offsets only for topic-partitions where all uploads succeeded; failed partition buffers are retained in memory and retried on the next flush cycle (prevents data loss from partial upload failures); shutdown flush uses an uncanceled token so MinIO writes complete on Ctrl+C; `kafka_partition` and `kafka_offset` columns provide audit lineage
|
||||||
@@ -149,6 +150,7 @@ IHostedServices (background):
|
|||||||
WardGatewayMetricsCollector → polls gateway status/buffer depth every 60s → ward_gateways_offline_gauge, ward_gateway_buffer_depth
|
WardGatewayMetricsCollector → polls gateway status/buffer depth every 60s → ward_gateways_offline_gauge, ward_gateway_buffer_depth
|
||||||
GatewayStaleDetectorService → polls gateways on interval → marks OFFLINE when heartbeat exceeds StaleThresholdMinutes
|
GatewayStaleDetectorService → polls gateways on interval → marks OFFLINE when heartbeat exceeds StaleThresholdMinutes
|
||||||
AlertQualityAggregatorService → periodically computes per-alert-type quality metrics → alert_quality_metrics table + Prometheus gauges
|
AlertQualityAggregatorService → periodically computes per-alert-type quality metrics → alert_quality_metrics table + Prometheus gauges
|
||||||
|
SimulationRunner (when Simulation:Enabled) → in-app scenario/session replay via loopback API; marks patients IsSimulated
|
||||||
ClinicalMetrics (singleton) → inline counters/histogram from ingest, qSOFA, NEWS2, GCS, SOFA, trend, suppression, bundle compliance, escalation paths, alert quality
|
ClinicalMetrics (singleton) → inline counters/histogram from ingest, qSOFA, NEWS2, GCS, SOFA, trend, suppression, bundle compliance, escalation paths, alert quality
|
||||||
|
|
||||||
Ward Gateway (VigilCare.WardGateway — separate deployable on port 5081):
|
Ward Gateway (VigilCare.WardGateway — separate deployable on port 5081):
|
||||||
@@ -610,7 +612,7 @@ vigilcare-dashboard/ # Phases 17–19, 22, 23, 27
|
|||||||
│ ├── composables/ # useChartData, useReplayControls, usePolling, useFeedback, useGcs, useSofa, useApiMode, useChartTheme, useFocusTrap, chartFormat, patientFormat, timelineFormat, chartMedications, wardSort, wardFilter, departmentFormat, sepsisFormat, alertAcknowledge, alertExplanation, criticalAlertDetect, useAlertNotification, useCriticalAlertPolling, handoffReport, vitalsForm, roleAccess, auditFormat, reconciliationFormat, thresholdForm, userForm
|
│ ├── composables/ # useChartData, useReplayControls, usePolling, useFeedback, useGcs, useSofa, useApiMode, useChartTheme, useFocusTrap, chartFormat, patientFormat, timelineFormat, chartMedications, wardSort, wardFilter, departmentFormat, sepsisFormat, alertAcknowledge, alertExplanation, criticalAlertDetect, useAlertNotification, useCriticalAlertPolling, handoffReport, vitalsForm, roleAccess, auditFormat, reconciliationFormat, thresholdForm, userForm
|
||||||
│ ├── plugins/ # medicationMarkerPlugin (Chart.js plugin for medication administration markers on vital charts)
|
│ ├── plugins/ # medicationMarkerPlugin (Chart.js plugin for medication administration markers on vital charts)
|
||||||
│ ├── stores/ # Pinia — ward (sort + filter + search), alerts (banner + polling), settings (sort prefs + sound mute), feedback, scoring, auth (login + refresh token rotation + logout + expiry redirect), departments, sepsis, operationsStore, alertQuality
|
│ ├── stores/ # Pinia — ward (sort + filter + search), alerts (banner + polling), settings (sort prefs + sound mute), feedback, scoring, auth (login + refresh token rotation + logout + expiry redirect), departments, sepsis, operationsStore, alertQuality
|
||||||
│ ├── views/ # LoginView, WardDashboard, PatientDetail, AlertCenter, FeedbackSummary, DepartmentOverviewView, SepsisBoardView, ThresholdManagementView, UserManagementView, AuditLogView, ReconciliationView, GatewayOperations, AlertQualityAnalytics
|
│ ├── views/ # LoginView, WardDashboard, PatientDetail, AlertCenter, FeedbackSummary, DepartmentOverviewView, SepsisBoardView, SimulationControlView, ThresholdManagementView, UserManagementView, AuditLogView, ReconciliationView, GatewayOperations, AlertQualityAnalytics
|
||||||
│ └── __tests__/ # Vitest — tests (store, feedback, replay, charts, alerts, ward, GCS, SOFA, qSOFA, PatientBanner, EncounterTimeline, patientFormat, timelineFormat, chartMedications, wardSort, wardFilter, departmentFormat, sepsisFormat, alertAcknowledge, criticalAlertDetect, HandoffReport, handoffReport, VitalsEntryForm, vitalsForm, useAlertStore, useWardStore, DepartmentOverviewView, SepsisBoardView, AcknowledgeModal, CriticalAlertBanner, roleAccess, ThresholdManagementView, thresholdForm, DischargeSummaryPanel, GatewayOperations, alertQuality)
|
│ └── __tests__/ # Vitest — tests (store, feedback, replay, charts, alerts, ward, GCS, SOFA, qSOFA, PatientBanner, EncounterTimeline, patientFormat, timelineFormat, chartMedications, wardSort, wardFilter, departmentFormat, sepsisFormat, alertAcknowledge, criticalAlertDetect, HandoffReport, handoffReport, VitalsEntryForm, vitalsForm, useAlertStore, useWardStore, DepartmentOverviewView, SepsisBoardView, AcknowledgeModal, CriticalAlertBanner, roleAccess, ThresholdManagementView, thresholdForm, DischargeSummaryPanel, GatewayOperations, alertQuality)
|
||||||
├── vite.config.js
|
├── vite.config.js
|
||||||
└── README.md # Dev quick start → docs/dashboard-guide.md
|
└── README.md # Dev quick start → docs/dashboard-guide.md
|
||||||
@@ -649,11 +651,13 @@ scripts/
|
|||||||
|
|
||||||
docs/
|
docs/
|
||||||
├── plans/ # Phase implementation and verification guides
|
├── plans/ # Phase implementation and verification guides
|
||||||
├── clinical-testing-guide.md # Doctor/nurse guide — alert review & feedback sessions
|
│ ├── vigilcare-clinical-roadmap.md # Phases 36–38 implementation order
|
||||||
|
│ └── phase-*-plan.md # Per-phase plans (incl. phase-36/37/38 simulation)
|
||||||
|
├── clinical-testing-guide.md # Doctor/nurse guide — self-service Simulation sessions A–D
|
||||||
├── dashboard-guide.md # VigilCare Dashboard user guide (ward, patient detail, charts)
|
├── dashboard-guide.md # VigilCare Dashboard user guide (ward, patient detail, charts)
|
||||||
├── dashboard-gap-analysis.md # Comprehensive gap analysis — P0–P5 clinical usefulness assessment
|
├── dashboard-gap-analysis.md # Comprehensive gap analysis — P0–P5 clinical usefulness assessment
|
||||||
├── patient-encounter-api-lifecycle.md # Full API walkthrough: registration → active stay → discharge
|
├── patient-encounter-api-lifecycle.md # Full API walkthrough: registration → active stay → discharge
|
||||||
├── simulator-guide.md # VigilCare.Simulator user guide
|
├── simulator-guide.md # VigilCare.Simulator CLI / CI user guide (clinicians use dashboard Simulation)
|
||||||
├── integration/
|
├── integration/
|
||||||
│ └── mirth-fhir-channels.md # Mirth Connect HL7v2→FHIR channel mapping (ADT A01/A03/A08, ORU R01)
|
│ └── mirth-fhir-channels.md # Mirth Connect HL7v2→FHIR channel mapping (ADT A01/A03/A08, ORU R01)
|
||||||
├── decisions/
|
├── decisions/
|
||||||
@@ -806,6 +810,12 @@ Without this, Prometheus may show target errors like:
|
|||||||
For full Docker troubleshooting and recovery steps, see:
|
For full Docker troubleshooting and recovery steps, see:
|
||||||
- `docs/docker-compose-usage-and-troubleshooting.md`
|
- `docs/docker-compose-usage-and-troubleshooting.md`
|
||||||
|
|
||||||
|
### Simulation mode (default off)
|
||||||
|
|
||||||
|
In-app scenario replay for clinical testing is controlled by the `Simulation` section in `appsettings.json` (or environment variables). **`Simulation:Enabled` defaults to `false`.** When enabled, the API registers session/scenario/purge endpoints and a loopback runner; clinicians use the dashboard **Simulation** page (session presets A–D, individual scenarios, speed control, ward reset — see `docs/clinical-testing-guide.md`). Never enable simulation against a database with real patient data — purge and replay only touch rows marked `IsSimulated`, but the feature is intended for evaluation environments only.
|
||||||
|
|
||||||
|
Key settings: `Simulation:ScenarioDirectory` (flat folder of scenario JSON; `sessions.json` may sit in that directory or its parent), `Simulation:MaxConcurrentRuns` (default 8 — enough for Session C’s seven concurrent patients), `Simulation:LoopbackBaseUrl`, and the `simulation.runner` service account password.
|
||||||
|
|
||||||
### Install and Run
|
### Install and Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -829,9 +839,11 @@ Health checks (anonymous, no JWT required):
|
|||||||
- `GET /health/live` — liveness probe (always 200 if process is running)
|
- `GET /health/live` — liveness probe (always 200 if process is running)
|
||||||
- `GET /health/ready` — readiness probe (checks PostgreSQL, Redis, Kafka, RabbitMQ, Elasticsearch)
|
- `GET /health/ready` — readiness probe (checks PostgreSQL, Redis, Kafka, RabbitMQ, Elasticsearch)
|
||||||
|
|
||||||
### Run the Simulator
|
### Run the Simulator (CLI / CI)
|
||||||
|
|
||||||
With the API running, replay a scenario from the repository root:
|
> **Clinicians:** prefer the dashboard **Simulation** page (Sessions / Scenarios / Reset ward). The CLI is for developers, CI, `validate` / `dry-run` / `replay-all` / `mimic-generate`, and `scripts/run-phase*-verification.sh`. See `docs/simulator-guide.md` §12.
|
||||||
|
|
||||||
|
With the API running and (for in-app use) `Simulation:Enabled=true`, replay a scenario from the repository root via CLI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet run --project VigilCare.Simulator -- replay \
|
dotnet run --project VigilCare.Simulator -- replay \
|
||||||
@@ -874,9 +886,11 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:5173` — log in with a demo account (e.g. `nurse.demo` / `DemoNurse1!`). **Virtual Ward** lists active patients sorted by NEWS2 score. Click a row for patient detail (vitals, alerts, charts, replay scrubbing, alert reasoning). Use **Alert Center** for hospital-wide triage. After reviewing alerts, rate them with the six feedback buttons and export results from **Feedback Summary** (`/feedback`).
|
Open `http://localhost:5173` — log in with a demo account (e.g. `nurse.demo` / `DemoNurse1!`). **Virtual Ward** lists active patients sorted by NEWS2 score. Click a row for patient detail (vitals, alerts, charts, replay scrubbing, alert reasoning). Use **Alert Center** for hospital-wide triage. After reviewing alerts, rate them with the six feedback buttons and export results from **Feedback Summary** or **Alert Quality**.
|
||||||
|
|
||||||
Replay a simulator scenario in another terminal to watch charts and alerts populate in real time. Run dashboard tests with `cd vigilcare-dashboard && npm test`. See `docs/dashboard-guide.md` for technical documentation and `docs/clinical-testing-guide.md` for structured clinician evaluation sessions.
|
With `Simulation:Enabled=true`, open **Simulation** in the sidebar: start a **Sessions** preset (A–D) or an individual scenario, watch runs on the progress panel, then **Reset ward** (type `RESET`) between testers. See `docs/clinical-testing-guide.md`.
|
||||||
|
|
||||||
|
Run dashboard tests with `cd vigilcare-dashboard && npm test`. See `docs/dashboard-guide.md` for technical documentation.
|
||||||
|
|
||||||
### Run Tests
|
### Run Tests
|
||||||
|
|
||||||
@@ -1603,6 +1617,24 @@ Each audit log entry includes `action`, `entityType`, `entityId`, `userId`, `use
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/alerts/quality-metrics` | Per-alert-type quality metric snapshots; optional `alertType`, `from`, `to` filters |
|
| GET | `/alerts/quality-metrics` | Per-alert-type quality metric snapshots; optional `alertType`, `from`, `to` filters |
|
||||||
| GET | `/alerts/quality-metrics/summary` | Aggregate alert quality rates across all alert types; optional `from`, `to` |
|
| GET | `/alerts/quality-metrics/summary` | Aggregate alert quality rates across all alert types; optional `from`, `to` |
|
||||||
|
| GET | `/alerts/quality-metrics/feedback` | Per-alert feedback rows; optional `scenarioId`, `from`, `to`; includes `scenarioId`/`sessionId` when `Simulation:Enabled` |
|
||||||
|
|
||||||
|
### Simulation (requires `Simulation:Enabled=true`)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/simulation/config` | Feature probe — `{ enabled }` (always 200; `enabled=false` when off) |
|
||||||
|
| GET | `/simulation/scenarios` | Scenario catalogue |
|
||||||
|
| GET | `/simulation/sessions` | Session presets with resolved scenario summaries |
|
||||||
|
| POST | `/simulation/sessions/{sessionId}/start` | Start all scenarios in a preset (optional `{ speed }`); all-or-nothing capacity check |
|
||||||
|
| POST | `/simulation/runs` | Start a single scenario run |
|
||||||
|
| GET | `/simulation/runs` | Active and recent runs |
|
||||||
|
| GET | `/simulation/runs/{runId}` | One run |
|
||||||
|
| POST | `/simulation/runs/{runId}/stop` | Cancel a run |
|
||||||
|
| GET | `/simulation/data/summary` | Simulated patient / observation / alert counts |
|
||||||
|
| DELETE | `/simulation/data` | Purge simulated patients and dependents (409 if runs active) |
|
||||||
|
|
||||||
|
Requires `simulation:run` (except `GET /simulation/config`, which needs `alerts:read`). Endpoints return 404 when simulation is disabled.
|
||||||
|
|
||||||
### Alert Feedback
|
### Alert Feedback
|
||||||
|
|
||||||
@@ -2292,7 +2324,7 @@ Observation history uses cursor pagination on `(recorded_at DESC, id DESC)`. Off
|
|||||||
|
|
||||||
## Implemented Phases
|
## Implemented Phases
|
||||||
|
|
||||||
Thirty-three phases from the project roadmap are implemented and verified, including the **Site & Gateway Registry** (Phase 20), the **Ward Gateway Service** (Phase 21), the **Dashboard Gap Analysis Fixes** (Phase 22), the **Degraded Operations Visibility** (Phase 23), the **Sepsis-3 clinical refactor** (Phases 27–29), the **FHIR R4 Inbound Facade** (Phase 30), **RBAC with clinical audit logging** (Phase 31), the **Alert Quality Analytics** (Phase 33), the **Explainable Alerts** (Phase 34), the **MIMIC-IV Replay Scenario Generator** (Phase 35), and the **Enhanced Dashboard** (department overview, sepsis bundle board, critical alert notifications, shift handoff reports, vitals entry, sortable/filterable ward table). Integration tests (`dotnet test`) and per-phase verification scripts cover Phases 8–15, 20–23, 25–31, 33–35. Phases 17–19 add the Vue dashboard and clinician feedback (Vitest in `vigilcare-dashboard/`).
|
Phases **1–38** from the project roadmap are implemented and verified, including the **Site & Gateway Registry** (Phase 20), the **Ward Gateway Service** (Phase 21), the **Dashboard Gap Analysis Fixes** (Phase 22), the **Degraded Operations Visibility** (Phase 23), the **Sepsis-3 clinical refactor** (Phases 27–29), the **FHIR R4 Inbound Facade** (Phase 30), **RBAC with clinical audit logging** (Phase 31), the **Alert Quality Analytics** (Phase 33), the **Explainable Alerts** (Phase 34), the **MIMIC-IV Replay Scenario Generator** (Phase 35), the **Enhanced Dashboard** (department overview, sepsis bundle board, critical alert notifications, shift handoff reports, vitals entry, sortable/filterable ward table), and **self-service clinical simulation** (Phases 36–38 — in-app runner, Simulation UI, session presets, ward purge, scenario-attributed feedback). Integration tests (`dotnet test`) and per-phase verification scripts cover Phases 8–15, 20–23, 25–31, 33–38. Phases 17–19 add the Vue dashboard and clinician feedback (Vitest in `vigilcare-dashboard/`). See also `docs/plans/vigilcare-clinical-roadmap.md`.
|
||||||
|
|
||||||
| Phase | Feature | Status |
|
| Phase | Feature | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -2329,8 +2361,11 @@ Thirty-three phases from the project roadmap are implemented and verified, inclu
|
|||||||
| 33 | **Alert Quality Analytics** — `AlertFeedback` entity with per-user-per-alert constraint; `POST /alerts/{id}/feedback` server-side feedback submission with `alerts:feedback` permission (Nurse, Physician, Admin); `AlertQualityMetric` entity stores per-alert-type quality snapshots (acknowledgement rate, false positive rate, useful rate, would-act rate, avg seconds to acknowledge/resolve); `AlertQualityAggregatorService` background service computes metrics periodically; `AlertQualityMetricsController` exposes `GET /alerts/quality-metrics` (time-range + alert type filter) and `GET /alerts/quality-metrics/summary`; `AlertFeedbackConfiguration` and `AlertQualityMetricConfiguration` EF Core configs; `SubmitAlertFeedbackRequestValidator`; Prometheus `alert_quality_useful_rate` and `alert_quality_false_positive_rate` gauges; Grafana `alert-quality-dashboard.json`; frontend `AlertQualityAnalytics.vue` with `AlertQualityChart.vue` and `alertQuality` Pinia store; `AlertQualityAnalyticsTests`; `run-phase33-verification.sh` | Done |
|
| 33 | **Alert Quality Analytics** — `AlertFeedback` entity with per-user-per-alert constraint; `POST /alerts/{id}/feedback` server-side feedback submission with `alerts:feedback` permission (Nurse, Physician, Admin); `AlertQualityMetric` entity stores per-alert-type quality snapshots (acknowledgement rate, false positive rate, useful rate, would-act rate, avg seconds to acknowledge/resolve); `AlertQualityAggregatorService` background service computes metrics periodically; `AlertQualityMetricsController` exposes `GET /alerts/quality-metrics` (time-range + alert type filter) and `GET /alerts/quality-metrics/summary`; `AlertFeedbackConfiguration` and `AlertQualityMetricConfiguration` EF Core configs; `SubmitAlertFeedbackRequestValidator`; Prometheus `alert_quality_useful_rate` and `alert_quality_false_positive_rate` gauges; Grafana `alert-quality-dashboard.json`; frontend `AlertQualityAnalytics.vue` with `AlertQualityChart.vue` and `alertQuality` Pinia store; `AlertQualityAnalyticsTests`; `run-phase33-verification.sh` | Done |
|
||||||
| 34 | **Explainable Alerts** — `AlertExplanation` value object (`ScoreContributor`, `TrendContext`, `MedicationContext`, `NarrativeSummary`); JSONB `ClinicalAlert.Explanation` column (immutable at creation); contributor builders for NEWS2, SOFA, GCS; `TrendContextBuilder`; `AlertExplanationBuilder` + `ClinicalAlertFactory`; NEWS2, SOFA, GCS, and `TrendDetector` wire explanation and include `explanation` in `alert.generated` outbox; `MedicationCorrelationHelper.TryGetContextAsync()` for structured medication context; `AlertResponse` DTO + `AlertResponseMapper`; GET/list/acknowledge/resolve return `AlertResponse`; ES indexer projects `NarrativeSummary`; data lake Parquet `explanation_json`; ward gateway `LocalClinicalAlert.ExplanationJson` + sync; dashboard `AlertReasoning.vue` + `alertExplanation.js`; simulator `ExpectedOutcomeValidator` with `narrativeContains`; `ExplainableAlertsTests`; `run-phase34-verification.sh` | Done |
|
| 34 | **Explainable Alerts** — `AlertExplanation` value object (`ScoreContributor`, `TrendContext`, `MedicationContext`, `NarrativeSummary`); JSONB `ClinicalAlert.Explanation` column (immutable at creation); contributor builders for NEWS2, SOFA, GCS; `TrendContextBuilder`; `AlertExplanationBuilder` + `ClinicalAlertFactory`; NEWS2, SOFA, GCS, and `TrendDetector` wire explanation and include `explanation` in `alert.generated` outbox; `MedicationCorrelationHelper.TryGetContextAsync()` for structured medication context; `AlertResponse` DTO + `AlertResponseMapper`; GET/list/acknowledge/resolve return `AlertResponse`; ES indexer projects `NarrativeSummary`; data lake Parquet `explanation_json`; ward gateway `LocalClinicalAlert.ExplanationJson` + sync; dashboard `AlertReasoning.vue` + `alertExplanation.js`; simulator `ExpectedOutcomeValidator` with `narrativeContains`; `ExplainableAlertsTests`; `run-phase34-verification.sh` | Done |
|
||||||
| 35 | **MIMIC-IV Replay Scenario Generator** — offline CLI tool in `VigilCare.Simulator/Mimic/` that reads MIMIC-IV CSV files (100 patients, 140 ICU stays, 668K chart events, 107K lab events from `docs/MIMIC-IV/`) and generates standard VigilCare scenario JSONs; `MimicCsvReader` streaming CSV parser with header-index lookup and filter predicate (memory-efficient for large files); `MimicItemMap` maps 17 chart event items (vitals, GCS, FiO₂, PaO₂, labs) and 8 lab event items to VigilCare observation codes with GCS text-to-numeric fallback dictionary, Fahrenheit-to-Celsius conversion, and blood pressure priority (non-invasive preferred over arterial); `MimicCareUnitMap` maps care units to departments and generates tags; `MimicDataLoader` streams chartevents/labevents/prescriptions filtered by stay_id + item ID set; `MimicScenarioBuilder` deduplicates BP, enforces 10-observation-per-cluster limit with priority-based splitting, computes offsetMinutes, parses medication doses; `MimicListCommand` (`mimic-list`) displays Spectre.Console table of available stays; `MimicGenerateCommand` (`mimic-generate`) produces scenario JSON with `--max-hours`, `--no-medications`, `--no-labs`, `--validate` options; generated scenarios pass `ScenarioValidator` and replay through the standard `replay` command | Done |
|
| 35 | **MIMIC-IV Replay Scenario Generator** — offline CLI tool in `VigilCare.Simulator/Mimic/` that reads MIMIC-IV CSV files (100 patients, 140 ICU stays, 668K chart events, 107K lab events from `docs/MIMIC-IV/`) and generates standard VigilCare scenario JSONs; `MimicCsvReader` streaming CSV parser with header-index lookup and filter predicate (memory-efficient for large files); `MimicItemMap` maps 17 chart event items (vitals, GCS, FiO₂, PaO₂, labs) and 8 lab event items to VigilCare observation codes with GCS text-to-numeric fallback dictionary, Fahrenheit-to-Celsius conversion, and blood pressure priority (non-invasive preferred over arterial); `MimicCareUnitMap` maps care units to departments and generates tags; `MimicDataLoader` streams chartevents/labevents/prescriptions filtered by stay_id + item ID set; `MimicScenarioBuilder` deduplicates BP, enforces 10-observation-per-cluster limit with priority-based splitting, computes offsetMinutes, parses medication doses; `MimicListCommand` (`mimic-list`) displays Spectre.Console table of available stays; `MimicGenerateCommand` (`mimic-generate`) produces scenario JSON with `--max-hours`, `--no-medications`, `--no-labs`, `--validate` options; generated scenarios pass `ScenarioValidator` and replay through the standard `replay` command | Done |
|
||||||
|
| 36 | **In-app simulation runner** — `Simulation:Enabled` gate; `ScenarioCatalog` + `SimulationRunner` hosted service; loopback API client; `IsSimulated` patient flag + filtered index; `simulation_runs` table; `GET/POST /api/v1/simulation/*` (config, scenarios, runs); `simulation:run` permission | Done |
|
||||||
|
| 37 | **Simulation control UI** — dashboard Simulation page with scenario catalogue, speed control, run progress panel, SIM badge / simulation mode banner | Done |
|
||||||
|
| 38 | **Self-service clinical testing sessions** — `sessions.json` presets; multi-run session start (all-or-nothing, staggered); simulated-data purge + typed reset UI; scenario attribution on alert-quality feedback/CSV; clinical testing guide rewritten for self-service | Done |
|
||||||
|
|
||||||
**Ward dashboard:** backend APIs (`GET /encounters` ward list with extended summary fields including SOFA/GCS/attending/admitted-at, `GET /qsofa/current`, `GET /qsofa/history`, `GET /gcs/history`, `GET /sepsis-bundles` hospital-wide list, `GET /operations/gateways` fleet management, `GET /users` user management, `GET /alerts/quality-metrics` alert quality, `GET /alerts/{id}` with structured explanation, CORS) and frontend SPA — `EncountersListTests`, `QsofaCurrentTests`, `GapAnalysisFixTests`, `OperationsApiTests`, `AlertQualityAnalyticsTests`, `ExplainableAlertsTests`, `vigilcare-dashboard` Vitest suite (replay scrubbing, feedback store, FeedbackButtons, FeedbackSummary, alert components, AlertReasoning, charts, ward table, ward sort, ward filter, department format, sepsis format, alert acknowledge, critical alert detect, handoff report, vitals form, GCS entry/history, SOFA panel/history, qSOFA history, scores panel, alert labels, PatientBanner, EncounterTimeline, medication chart markers, AcknowledgeModal, CriticalAlertBanner, DepartmentOverviewView, SepsisBoardView, VitalsEntryForm, useAlertStore, useWardStore, roleAccess, ThresholdManagementView, DischargeSummaryPanel, GatewayOperations, alertQuality).
|
**Ward dashboard:** backend APIs (`GET /encounters` ward list with extended summary fields including SOFA/GCS/attending/admitted-at/`isSimulated`, `GET /qsofa/current`, `GET /qsofa/history`, `GET /gcs/history`, `GET /sepsis-bundles` hospital-wide list, `GET /operations/gateways` fleet management, `GET /users` user management, `GET /alerts/quality-metrics` alert quality (+ `/feedback` with scenario attribution), `GET /alerts/{id}` with structured explanation, `/api/v1/simulation/*` when enabled, CORS) and frontend SPA — `EncountersListTests`, `QsofaCurrentTests`, `GapAnalysisFixTests`, `OperationsApiTests`, `AlertQualityAnalyticsTests`, `ExplainableAlertsTests`, simulation suite (`SimulationRunnerTests`, `SimulationSessionTests`, `SimulationPurgeTests`, `SessionCatalogTests`, `AlertQualityScenarioAttributionTests`), `vigilcare-dashboard` Vitest suite (replay scrubbing, feedback store, FeedbackButtons, FeedbackSummary, alert components, AlertReasoning, charts, ward table, ward sort, ward filter, department format, sepsis format, alert acknowledge, critical alert detect, handoff report, vitals form, GCS entry/history, SOFA panel/history, qSOFA history, scores panel, alert labels, PatientBanner, EncounterTimeline, medication chart markers, AcknowledgeModal, CriticalAlertBanner, DepartmentOverviewView, SepsisBoardView, VitalsEntryForm, useAlertStore, useWardStore, roleAccess, ThresholdManagementView, DischargeSummaryPanel, GatewayOperations, alertQuality, SimulationControlView, SessionCard, ResetWardPanel, ScenarioCard, SimulationRunPanel, SimulationModeBanner, useSimulationStore).
|
||||||
|
|
||||||
**Enhanced Dashboard (post-Phase 22):** Major dashboard feature expansion addressing clinical workflow gaps. **Department Overview** (`/departments`) — unit-level snapshot cards showing patient count, critical/alert/bundle totals per department with acuity distribution bars; click-through to ward filtered by department. **Sepsis Bundle Board** (`/sepsis`) — real-time bundle compliance tracking with countdown timers to 1-hour deadline, urgency-sorted (overdue → at-risk → on-track), live 1-second tick updates. **Critical Alert Notifications** — `CriticalAlertBanner` surfaces new critical alerts from polling cycle with audible 880Hz two-tone alert, browser title flash, and native `Notification` API integration; mute toggle persisted in settings. **Shift Handoff Report** — `HandoffReport.vue` generates SBAR-format (Situation, Background, Assessment, Recommendation) structured reports for all ward patients, enriched with latest vitals, open alerts, pending orders, and sepsis bundle status; ward summary with department stats; print/PDF export. **Vitals Entry Form** — `VitalsEntryForm.vue` on patient detail page enables manual observation recording (7 vital parameters with AVPU dropdown) with client-side plausibility validation matching server-side ranges. **Ward Table Enhancements** — multi-column sorting (room, patient, department, NEWS2, qSOFA, sepsis, alerts) with sortable column headers, debounced patient search (name/MRN), quick-filter toggles (critical, has alerts, active sepsis), clear-all filters. **Acknowledge Modal** — role-aware acknowledgment with clinician identity pre-populated from JWT, role-specific guidance text, and acknowledgment note preview. Backend additions: `GET /sepsis-bundles` paginated hospital-wide list with `SepsisBundleSummary` (patient demographics, elements, deadlines); `WardEncounterSummary` extended with `sofaScore`, `sofaDelta`, `gcsScore`, `gcsClassification`, `lastObservationAt`, `attendingPhysician`, `admittedAt`.
|
**Enhanced Dashboard (post-Phase 22):** Major dashboard feature expansion addressing clinical workflow gaps. **Department Overview** (`/departments`) — unit-level snapshot cards showing patient count, critical/alert/bundle totals per department with acuity distribution bars; click-through to ward filtered by department. **Sepsis Bundle Board** (`/sepsis`) — real-time bundle compliance tracking with countdown timers to 1-hour deadline, urgency-sorted (overdue → at-risk → on-track), live 1-second tick updates. **Critical Alert Notifications** — `CriticalAlertBanner` surfaces new critical alerts from polling cycle with audible 880Hz two-tone alert, browser title flash, and native `Notification` API integration; mute toggle persisted in settings. **Shift Handoff Report** — `HandoffReport.vue` generates SBAR-format (Situation, Background, Assessment, Recommendation) structured reports for all ward patients, enriched with latest vitals, open alerts, pending orders, and sepsis bundle status; ward summary with department stats; print/PDF export. **Vitals Entry Form** — `VitalsEntryForm.vue` on patient detail page enables manual observation recording (7 vital parameters with AVPU dropdown) with client-side plausibility validation matching server-side ranges. **Ward Table Enhancements** — multi-column sorting (room, patient, department, NEWS2, qSOFA, sepsis, alerts) with sortable column headers, debounced patient search (name/MRN), quick-filter toggles (critical, has alerts, active sepsis), clear-all filters. **Acknowledge Modal** — role-aware acknowledgment with clinician identity pre-populated from JWT, role-specific guidance text, and acknowledgment note preview. Backend additions: `GET /sepsis-bundles` paginated hospital-wide list with `SepsisBundleSummary` (patient demographics, elements, deadlines); `WardEncounterSummary` extended with `sofaScore`, `sofaDelta`, `gcsScore`, `gcsClassification`, `lastObservationAt`, `attendingPhysician`, `admittedAt`.
|
||||||
|
|
||||||
@@ -2350,11 +2385,13 @@ Thirty-three phases from the project roadmap are implemented and verified, inclu
|
|||||||
|
|
||||||
**Degraded Operations Visibility (Phase 23):** Gateway fleet operations panel with stale gateway auto-detection (`GatewayStaleDetectorService`), discharge summary API with MinIO PDF retrieval, admin panels for user management, threshold management, audit log browsing, and reconciliation viewing. Frontend adds role-aware sidebar navigation, degraded-mode banner for offline gateways, and comprehensive admin CRUD views.
|
**Degraded Operations Visibility (Phase 23):** Gateway fleet operations panel with stale gateway auto-detection (`GatewayStaleDetectorService`), discharge summary API with MinIO PDF retrieval, admin panels for user management, threshold management, audit log browsing, and reconciliation viewing. Frontend adds role-aware sidebar navigation, degraded-mode banner for offline gateways, and comprehensive admin CRUD views.
|
||||||
|
|
||||||
**Alert Quality Analytics (Phase 33):** Server-side clinician feedback persisted as `AlertFeedback` entities (one per user per alert, six feedback types). `AlertQualityAggregatorService` periodically computes per-alert-type quality metrics (acknowledgement rate, false positive rate, useful rate, would-act rate, response times). REST API exposes quality metric snapshots and aggregate summaries. Grafana dashboard visualizes alert quality trends. Frontend analytics view with quality charts.
|
**Alert Quality Analytics (Phase 33):** Server-side clinician feedback persisted as `AlertFeedback` entities (one per user per alert, six feedback types). `AlertQualityAggregatorService` periodically computes per-alert-type quality metrics (acknowledgement rate, false positive rate, useful rate, would-act rate, response times). REST API exposes quality metric snapshots and aggregate summaries. Grafana dashboard visualizes alert quality trends. Frontend analytics view with quality charts. Phase 38 extends this with `GET /alerts/quality-metrics/feedback` scenario attribution (`scenarioId` / `sessionId` via `SimulationRun.EncounterId` join), scenario filter, by-scenario breakdown, and CSV columns.
|
||||||
|
|
||||||
**Explainable Alerts (Phase 34):** Composite alerts (NEWS2, SOFA, GCS, rapid deterioration) carry an immutable JSONB `explanation` snapshot at creation — score contributors with raw values and normal ranges, trend context (percent change, duration, direction), structured medication context, and a bedside `NarrativeSummary`. `AlertResponse` exposes explanation on GET/list/acknowledge/resolve. Downstream consumers (Elasticsearch indexer, data lake Parquet, ward gateway sync, Kafka `alert.generated`) propagate explanation without breaking legacy consumers. Dashboard `AlertReasoning.vue` renders structured reasoning. Simulator validates `narrativeContains` on key scenarios.
|
**Explainable Alerts (Phase 34):** Composite alerts (NEWS2, SOFA, GCS, rapid deterioration) carry an immutable JSONB `explanation` snapshot at creation — score contributors with raw values and normal ranges, trend context (percent change, duration, direction), structured medication context, and a bedside `NarrativeSummary`. `AlertResponse` exposes explanation on GET/list/acknowledge/resolve. Downstream consumers (Elasticsearch indexer, data lake Parquet, ward gateway sync, Kafka `alert.generated`) propagate explanation without breaking legacy consumers. Dashboard `AlertReasoning.vue` renders structured reasoning. Simulator validates `narrativeContains` on key scenarios.
|
||||||
|
|
||||||
**MIMIC-IV Replay Scenario Generator (Phase 35):** Offline CLI tool that converts real de-identified ICU data from MIT's MIMIC-IV dataset into VigilCare scenario JSONs. `mimic-list` browses 140 ICU stays across 100 patients with demographics, care unit, LOS, and outcome. `mimic-generate` produces a scenario from a specific stay ID with options for duration capping, medication/lab exclusion, and inline validation. The streaming CSV parser handles 668K-row chartevents efficiently by filtering at the string level before allocating records. Item mappings cover 17 chart event items (vitals, GCS text-to-numeric, FiO₂, PaO₂, ICU labs) and 8 lab event items (creatinine, platelets, bilirubin, lactate, WBC, potassium, glucose, PaO₂). Blood pressure deduplication prefers non-invasive over arterial readings. The 10-observation-per-cluster limit is enforced by priority-based splitting (vitals first, labs spill to the next offset). Generated scenarios are structurally identical to hand-crafted ones and replay through the existing `replay` command, driving NEWS2, SOFA, GCS, qSOFA, trend detection, and alerting on real patient trajectories.
|
**MIMIC-IV Replay Scenario Generator (Phase 35):** Offline CLI tool that converts real de-identified ICU data from MIT's MIMIC-IV dataset into VigilCare scenario JSONs. `mimic-list` browses 140 ICU stays across 100 patients with demographics, care unit, LOS, and outcome. `mimic-generate` produces a scenario from a specific stay ID with options for duration capping, medication/lab exclusion, and inline validation. The streaming CSV parser handles 668K-row chartevents efficiently by filtering at the string level before allocating records. Item mappings cover 17 chart event items (vitals, GCS text-to-numeric, FiO₂, PaO₂, ICU labs) and 8 lab event items (creatinine, platelets, bilirubin, lactate, WBC, potassium, glucose, PaO₂). Blood pressure deduplication prefers non-invasive over arterial readings. The 10-observation-per-cluster limit is enforced by priority-based splitting (vitals first, labs spill to the next offset). Generated scenarios are structurally identical to hand-crafted ones and replay through the existing `replay` command (or in-app Simulation), driving NEWS2, SOFA, GCS, qSOFA, trend detection, and alerting on real patient trajectories.
|
||||||
|
|
||||||
|
**Self-service clinical simulation (Phases 36–38):** Default-off in-app runner (`Simulation:Enabled`) loads scenario JSON and `sessions.json` presets, replays via loopback as `simulation.runner`, and marks patients `IsSimulated`. Dashboard **Simulation** page offers Sessions (A–D presets with all-or-nothing staggered multi-run start), Scenarios catalogue, speed control, run progress, and typed ward reset that purges only simulated data (`SIMULATION_DATA_PURGED` audit). Alert Quality feedback joins to `SimulationRun` for scenario/session attribution. Clinical testing guide rewritten so doctors and nurses complete Sessions A–D with no terminal; CLI remains the developer/CI tool (`docs/simulator-guide.md` §12). Roadmap: `docs/plans/vigilcare-clinical-roadmap.md`.
|
||||||
|
|
||||||
**Post-phase hardening (after Phase 31):**
|
**Post-phase hardening (after Phase 31):**
|
||||||
- **FHIR R4 read/search** — `FhirReadController` adds `GET /fhir/R4/Patient/{id}`, `GET /fhir/R4/Patient` (search by `identifier`), `GET /fhir/R4/Encounter/{id}`, `GET /fhir/R4/Encounter` (search by `patient`/`status`); new `fhir:read` permission for Admin and Integration roles; CapabilityStatement updated to advertise `read` and `searchType` interactions for Patient and Encounter
|
- **FHIR R4 read/search** — `FhirReadController` adds `GET /fhir/R4/Patient/{id}`, `GET /fhir/R4/Patient` (search by `identifier`), `GET /fhir/R4/Encounter/{id}`, `GET /fhir/R4/Encounter` (search by `patient`/`status`); new `fhir:read` permission for Admin and Integration roles; CapabilityStatement updated to advertise `read` and `searchType` interactions for Patient and Encounter
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"id": "session-a-orientation",
|
||||||
|
"name": "Session A — Quick orientation",
|
||||||
|
"goal": "Learn the interface and rate at least 5 alerts.",
|
||||||
|
"estimatedMinutes": 25,
|
||||||
|
"defaultSpeed": 60,
|
||||||
|
"scenarios": ["stable-baseline-01", "uti-sepsis-elderly-01"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "session-b-alert-quality",
|
||||||
|
"name": "Session B — Alert quality deep dive",
|
||||||
|
"goal": "Compare alert types across contrasting scenarios.",
|
||||||
|
"estimatedMinutes": 50,
|
||||||
|
"defaultSpeed": 120,
|
||||||
|
"scenarios": [
|
||||||
|
"medication-false-alarm-01",
|
||||||
|
"uti-sepsis-elderly-01",
|
||||||
|
"respiratory-failure-asthma-01"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "session-c-ward-round",
|
||||||
|
"name": "Session C — Ward workflow",
|
||||||
|
"goal": "Test prioritisation, search, and handoff on a full ward.",
|
||||||
|
"estimatedMinutes": 30,
|
||||||
|
"defaultSpeed": 240,
|
||||||
|
"scenarios": [
|
||||||
|
"stable-baseline-01", "uti-sepsis-elderly-01", "post-op-hemorrhage-01",
|
||||||
|
"respiratory-failure-asthma-01", "hypothermia-elderly-01",
|
||||||
|
"dka-electrolyte-01", "cardiac-arrest-post-mi-01"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "session-d-new-features",
|
||||||
|
"name": "Session D — New features",
|
||||||
|
"goal": "Evaluate department overview, sepsis board, handoff, and vitals entry.",
|
||||||
|
"estimatedMinutes": 40,
|
||||||
|
"defaultSpeed": 120,
|
||||||
|
"scenarios": [
|
||||||
|
"uti-sepsis-elderly-01", "sepsis-sofa-progression-01",
|
||||||
|
"neurological-decline-gcs-01", "post-op-hemorrhage-01"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
[Collection("Integration")]
|
||||||
|
public class AlertQualityScenarioAttributionTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly ApiFixture _fixture;
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
public AlertQualityScenarioAttributionTests(ApiFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
_client = fixture.CreateClient();
|
||||||
|
_client.AsNurse();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
await DbResetHelper.ResetAsync(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AlertQuality_FiltersByScenarioId()
|
||||||
|
{
|
||||||
|
Guid matchFeedbackId;
|
||||||
|
Guid otherFeedbackId;
|
||||||
|
|
||||||
|
using (var scope = _fixture.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
var patientA = NewPatient("MRN-SCEN-A");
|
||||||
|
var patientB = NewPatient("MRN-SCEN-B");
|
||||||
|
var encounterA = NewEncounter(patientA.Id);
|
||||||
|
var encounterB = NewEncounter(patientB.Id);
|
||||||
|
var alertA = NewAlert(encounterA.Id, patientA.Id, AlertType.News2Warning);
|
||||||
|
var alertB = NewAlert(encounterB.Id, patientB.Id, AlertType.News2Emergency);
|
||||||
|
|
||||||
|
db.Patients.AddRange(patientA, patientB);
|
||||||
|
db.Encounters.AddRange(encounterA, encounterB);
|
||||||
|
db.ClinicalAlerts.AddRange(alertA, alertB);
|
||||||
|
db.SimulationRuns.Add(new SimulationRun
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ScenarioId = "uti-sepsis-elderly-01",
|
||||||
|
ScenarioName = "UTI Sepsis",
|
||||||
|
Speed = 60,
|
||||||
|
Status = SimulationRunStatus.Completed,
|
||||||
|
PatientId = patientA.Id,
|
||||||
|
EncounterId = encounterA.Id,
|
||||||
|
SessionId = "session-b-alert-quality",
|
||||||
|
StartedByUserId = "tester",
|
||||||
|
StartedAt = DateTimeOffset.UtcNow.AddHours(-1),
|
||||||
|
CompletedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
db.SimulationRuns.Add(new SimulationRun
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ScenarioId = "medication-false-alarm-01",
|
||||||
|
ScenarioName = "Med False Alarm",
|
||||||
|
Speed = 60,
|
||||||
|
Status = SimulationRunStatus.Completed,
|
||||||
|
PatientId = patientB.Id,
|
||||||
|
EncounterId = encounterB.Id,
|
||||||
|
SessionId = "session-b-alert-quality",
|
||||||
|
StartedByUserId = "tester",
|
||||||
|
StartedAt = DateTimeOffset.UtcNow.AddHours(-1),
|
||||||
|
CompletedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
|
||||||
|
var fbA = new AlertFeedback
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
AlertId = alertA.Id,
|
||||||
|
UserId = Guid.NewGuid(),
|
||||||
|
FeedbackType = AlertFeedbackType.WouldAct,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-10),
|
||||||
|
};
|
||||||
|
var fbB = new AlertFeedback
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
AlertId = alertB.Id,
|
||||||
|
UserId = Guid.NewGuid(),
|
||||||
|
FeedbackType = AlertFeedbackType.FalsePositive,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||||
|
};
|
||||||
|
db.AlertFeedbacks.AddRange(fbA, fbB);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
matchFeedbackId = fbA.Id;
|
||||||
|
otherFeedbackId = fbB.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
var all = await _client.GetAsync("/api/v1/alerts/quality-metrics/feedback");
|
||||||
|
all.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var allBody = await all.Content.ReadFromJsonAsync<JsonDocument>();
|
||||||
|
var allItems = allBody!.RootElement.GetProperty("data").GetProperty("items");
|
||||||
|
allItems.GetArrayLength().Should().Be(2);
|
||||||
|
|
||||||
|
var match = allItems.EnumerateArray()
|
||||||
|
.Single(e => e.GetProperty("id").GetGuid() == matchFeedbackId);
|
||||||
|
match.GetProperty("scenarioId").GetString().Should().Be("uti-sepsis-elderly-01");
|
||||||
|
match.GetProperty("sessionId").GetString().Should().Be("session-b-alert-quality");
|
||||||
|
|
||||||
|
var filtered = await _client.GetAsync(
|
||||||
|
"/api/v1/alerts/quality-metrics/feedback?scenarioId=uti-sepsis-elderly-01");
|
||||||
|
filtered.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var filteredBody = await filtered.Content.ReadFromJsonAsync<JsonDocument>();
|
||||||
|
var filteredItems = filteredBody!.RootElement.GetProperty("data").GetProperty("items");
|
||||||
|
filteredItems.GetArrayLength().Should().Be(1);
|
||||||
|
filteredItems[0].GetProperty("id").GetGuid().Should().Be(matchFeedbackId);
|
||||||
|
filteredItems[0].GetProperty("scenarioId").GetString().Should().Be("uti-sepsis-elderly-01");
|
||||||
|
|
||||||
|
// Other scenario's feedback must not leak into the filter.
|
||||||
|
filteredItems.EnumerateArray()
|
||||||
|
.Any(e => e.GetProperty("id").GetGuid() == otherFeedbackId)
|
||||||
|
.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Patient NewPatient(string mrn) => new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Mrn = mrn,
|
||||||
|
FirstName = "Sim",
|
||||||
|
LastName = "Patient",
|
||||||
|
DateOfBirth = new DateOnly(1980, 1, 1),
|
||||||
|
Gender = "F",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
IsSimulated = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Encounter NewEncounter(Guid patientId) => new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
PatientId = patientId,
|
||||||
|
EncounterType = EncounterType.Inpatient,
|
||||||
|
Status = EncounterStatus.Active,
|
||||||
|
Department = Department.Icu,
|
||||||
|
AttendingPhysician = "Dr. Test",
|
||||||
|
AdmittedAt = DateTimeOffset.UtcNow,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ClinicalAlert NewAlert(Guid encounterId, Guid patientId, AlertType type) => new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
EncounterId = encounterId,
|
||||||
|
PatientId = patientId,
|
||||||
|
AlertType = type,
|
||||||
|
Severity = AlertSeverity.Warning,
|
||||||
|
Details = "Test alert",
|
||||||
|
Status = AlertStatus.Acknowledged,
|
||||||
|
TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30),
|
||||||
|
AcknowledgedAt = DateTimeOffset.UtcNow.AddMinutes(-20),
|
||||||
|
AcknowledgedBy = "nurse",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"id": "session-test-one",
|
||||||
|
"name": "Test Session One",
|
||||||
|
"goal": "Single fixture run for session start tests.",
|
||||||
|
"estimatedMinutes": 2,
|
||||||
|
"defaultSpeed": 120,
|
||||||
|
"scenarios": ["minimal-sim-01"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "session-test-two",
|
||||||
|
"name": "Test Session Two",
|
||||||
|
"goal": "Two concurrent fixture runs.",
|
||||||
|
"estimatedMinutes": 5,
|
||||||
|
"defaultSpeed": 60,
|
||||||
|
"scenarios": ["minimal-sim-01", "minimal-sim-01"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "session-test-three",
|
||||||
|
"name": "Test Session Three",
|
||||||
|
"goal": "Over capacity when MaxConcurrentRuns is 2.",
|
||||||
|
"estimatedMinutes": 5,
|
||||||
|
"defaultSpeed": 60,
|
||||||
|
"scenarios": ["minimal-sim-01", "minimal-sim-01", "minimal-sim-01"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using VigilCare.Simulation;
|
||||||
|
|
||||||
|
public class SessionCatalogTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SessionCatalog_InvalidScenarioId_FailsAtStartup()
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "vigilcare-session-catalog-" + Guid.NewGuid());
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(dir, "sessions.json"), """
|
||||||
|
{
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"id": "broken-session",
|
||||||
|
"name": "Broken",
|
||||||
|
"goal": "Should fail",
|
||||||
|
"estimatedMinutes": 1,
|
||||||
|
"defaultSpeed": 60,
|
||||||
|
"scenarios": ["does-not-exist-01"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var scenarios = new StubScenarioCatalog();
|
||||||
|
var options = Options.Create(new SimulationOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
ScenarioDirectory = dir,
|
||||||
|
});
|
||||||
|
|
||||||
|
var act = () => new SessionCatalog(options, scenarios);
|
||||||
|
|
||||||
|
act.Should().Throw<InvalidOperationException>()
|
||||||
|
.WithMessage("*does-not-exist-01*");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Directory.Delete(dir, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SessionCatalog_ValidManifest_LoadsPresets()
|
||||||
|
{
|
||||||
|
var fixtureDir = ApiFixture.SimulationScenarioDirectory;
|
||||||
|
var catalog = new ScenarioCatalog(Options.Create(new SimulationOptions
|
||||||
|
{
|
||||||
|
ScenarioDirectory = fixtureDir,
|
||||||
|
}));
|
||||||
|
var sessions = new SessionCatalog(
|
||||||
|
Options.Create(new SimulationOptions { ScenarioDirectory = fixtureDir }),
|
||||||
|
catalog);
|
||||||
|
|
||||||
|
sessions.ListSessions().Should().NotBeEmpty();
|
||||||
|
sessions.GetById("session-test-two")!.Scenarios.Should().HaveCount(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubScenarioCatalog : IScenarioCatalog
|
||||||
|
{
|
||||||
|
public IReadOnlyList<ScenarioFile> ListScenarios() => Array.Empty<ScenarioFile>();
|
||||||
|
public ScenarioFile? GetById(string scenarioId) => null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
[Collection("Integration")]
|
||||||
|
public class SimulationPurgeTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly ApiFixture _fixture;
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
private ISimulationRunner _runner = null!;
|
||||||
|
private TestSimulationClientFactory _clientFactory = null!;
|
||||||
|
|
||||||
|
public SimulationPurgeTests(ApiFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
_client = fixture.CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||||
|
await DbResetHelper.ResetAsync(db);
|
||||||
|
await DataSeeder.SeedThresholdsOnlyAsync(db, redis);
|
||||||
|
|
||||||
|
_runner = _fixture.Services.GetRequiredService<ISimulationRunner>();
|
||||||
|
_clientFactory = _fixture.SimulationClientFactory;
|
||||||
|
_clientFactory.HangOnCreate = false;
|
||||||
|
_clientFactory.FailOnCreate = false;
|
||||||
|
|
||||||
|
foreach (var run in _runner.ListRuns())
|
||||||
|
_runner.Cancel(run.RunId);
|
||||||
|
await WaitForAsync(() => !_runner.HasActiveRuns());
|
||||||
|
_runner.ClearRegistry();
|
||||||
|
|
||||||
|
_client.DefaultRequestHeaders.Remove("X-Test-Role");
|
||||||
|
_client.DefaultRequestHeaders.Remove("X-Test-User-Id");
|
||||||
|
_client.AsNurse();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisposeAsync()
|
||||||
|
{
|
||||||
|
_clientFactory.HangOnCreate = false;
|
||||||
|
_clientFactory.FailOnCreate = false;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Purge_RemovesOnlySimulatedPatients()
|
||||||
|
{
|
||||||
|
using (var scope = _fixture.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
var realPatient = new Patient
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Mrn = "MRN-REAL-001",
|
||||||
|
FirstName = "Real",
|
||||||
|
LastName = "Patient",
|
||||||
|
DateOfBirth = new DateOnly(1975, 4, 1),
|
||||||
|
Gender = "M",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
IsSimulated = false,
|
||||||
|
};
|
||||||
|
var realEncounter = new Encounter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
PatientId = realPatient.Id,
|
||||||
|
EncounterType = EncounterType.Inpatient,
|
||||||
|
Status = EncounterStatus.Active,
|
||||||
|
Department = Department.Icu,
|
||||||
|
AttendingPhysician = "Dr. Real",
|
||||||
|
AdmittedAt = DateTimeOffset.UtcNow,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
var realObs = new Observation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
EncounterId = realEncounter.Id,
|
||||||
|
ObservationCode = "HEART_RATE",
|
||||||
|
Value = 70,
|
||||||
|
Unit = "bpm",
|
||||||
|
Source = ObservationSource.Manual,
|
||||||
|
RecordedAt = DateTimeOffset.UtcNow,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
var realAlert = new ClinicalAlert
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
EncounterId = realEncounter.Id,
|
||||||
|
PatientId = realPatient.Id,
|
||||||
|
AlertType = AlertType.News2Warning,
|
||||||
|
Severity = AlertSeverity.Warning,
|
||||||
|
Details = "Real patient alert",
|
||||||
|
Status = AlertStatus.Acknowledged,
|
||||||
|
TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-10),
|
||||||
|
AcknowledgedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||||
|
AcknowledgedBy = "nurse",
|
||||||
|
};
|
||||||
|
var realFeedback = new AlertFeedback
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
AlertId = realAlert.Id,
|
||||||
|
UserId = Guid.NewGuid(),
|
||||||
|
FeedbackType = AlertFeedbackType.Useful,
|
||||||
|
Comment = "Keep me",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
var simPatient = new Patient
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Mrn = "MRN-SIM-001",
|
||||||
|
FirstName = "Sim",
|
||||||
|
LastName = "Patient",
|
||||||
|
DateOfBirth = new DateOnly(1980, 1, 1),
|
||||||
|
Gender = "F",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
IsSimulated = true,
|
||||||
|
};
|
||||||
|
var simEncounter = new Encounter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
PatientId = simPatient.Id,
|
||||||
|
EncounterType = EncounterType.Inpatient,
|
||||||
|
Status = EncounterStatus.Active,
|
||||||
|
Department = Department.GeneralMedicine,
|
||||||
|
AttendingPhysician = "Dr. Sim",
|
||||||
|
AdmittedAt = DateTimeOffset.UtcNow,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
var simObs = new Observation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
EncounterId = simEncounter.Id,
|
||||||
|
ObservationCode = "HEART_RATE",
|
||||||
|
Value = 110,
|
||||||
|
Unit = "bpm",
|
||||||
|
Source = ObservationSource.Manual,
|
||||||
|
RecordedAt = DateTimeOffset.UtcNow,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
var simAlert = new ClinicalAlert
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
EncounterId = simEncounter.Id,
|
||||||
|
PatientId = simPatient.Id,
|
||||||
|
AlertType = AlertType.News2Emergency,
|
||||||
|
Severity = AlertSeverity.Critical,
|
||||||
|
Details = "Simulated alert",
|
||||||
|
Status = AlertStatus.Open,
|
||||||
|
TriggeredAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Patients.AddRange(realPatient, simPatient);
|
||||||
|
db.Encounters.AddRange(realEncounter, simEncounter);
|
||||||
|
db.Observations.AddRange(realObs, simObs);
|
||||||
|
db.ClinicalAlerts.AddRange(realAlert, simAlert);
|
||||||
|
db.AlertFeedbacks.Add(realFeedback);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp = await _client.DeleteAsync("/api/v1/simulation/data");
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var data = body.GetProperty("data");
|
||||||
|
data.GetProperty("patientsDeleted").GetInt32().Should().Be(1);
|
||||||
|
data.GetProperty("encountersDeleted").GetInt32().Should().Be(1);
|
||||||
|
data.GetProperty("alertsDeleted").GetInt32().Should().Be(1);
|
||||||
|
|
||||||
|
using (var scope = _fixture.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
(await db.Patients.AnyAsync(p => p.Mrn == "MRN-SIM-001")).Should().BeFalse();
|
||||||
|
|
||||||
|
var real = await db.Patients.SingleAsync(p => p.Mrn == "MRN-REAL-001");
|
||||||
|
real.IsSimulated.Should().BeFalse();
|
||||||
|
|
||||||
|
(await db.Observations.CountAsync(o =>
|
||||||
|
db.Encounters.Any(e => e.Id == o.EncounterId && e.PatientId == real.Id)))
|
||||||
|
.Should().Be(1);
|
||||||
|
(await db.ClinicalAlerts.CountAsync(a => a.PatientId == real.Id)).Should().Be(1);
|
||||||
|
(await db.AlertFeedbacks.CountAsync()).Should().Be(1);
|
||||||
|
(await db.Patients.CountAsync(p => p.IsSimulated)).Should().Be(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Purge_CascadesDependentRows()
|
||||||
|
{
|
||||||
|
Guid simPatientId;
|
||||||
|
Guid simEncounterId;
|
||||||
|
|
||||||
|
using (var scope = _fixture.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
var simPatient = new Patient
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Mrn = "MRN-SIM-CASCADE",
|
||||||
|
FirstName = "Cascade",
|
||||||
|
LastName = "Sim",
|
||||||
|
DateOfBirth = new DateOnly(1982, 2, 2),
|
||||||
|
Gender = "M",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
IsSimulated = true,
|
||||||
|
};
|
||||||
|
var simEncounter = new Encounter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
PatientId = simPatient.Id,
|
||||||
|
EncounterType = EncounterType.Inpatient,
|
||||||
|
Status = EncounterStatus.Active,
|
||||||
|
Department = Department.Icu,
|
||||||
|
AttendingPhysician = "Dr. Cascade",
|
||||||
|
AdmittedAt = DateTimeOffset.UtcNow,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
simPatientId = simPatient.Id;
|
||||||
|
simEncounterId = simEncounter.Id;
|
||||||
|
|
||||||
|
db.Patients.Add(simPatient);
|
||||||
|
db.Encounters.Add(simEncounter);
|
||||||
|
db.Observations.Add(new Observation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
EncounterId = simEncounter.Id,
|
||||||
|
ObservationCode = "HEART_RATE",
|
||||||
|
Value = 120,
|
||||||
|
Unit = "bpm",
|
||||||
|
Source = ObservationSource.Manual,
|
||||||
|
RecordedAt = DateTimeOffset.UtcNow,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
db.News2Scores.Add(new News2Score
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
EncounterId = simEncounter.Id,
|
||||||
|
PatientId = simPatient.Id,
|
||||||
|
TotalScore = 5,
|
||||||
|
RiskLevel = "MEDIUM",
|
||||||
|
CalculatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
db.GcsScores.Add(new GcsScore
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
EncounterId = simEncounter.Id,
|
||||||
|
PatientId = simPatient.Id,
|
||||||
|
EyeScore = 4,
|
||||||
|
VerbalScore = 5,
|
||||||
|
MotorScore = 6,
|
||||||
|
TotalScore = 15,
|
||||||
|
Classification = "MILD",
|
||||||
|
CalculatedAt = DateTimeOffset.UtcNow,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
db.SofaScores.Add(new SofaScore
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
EncounterId = simEncounter.Id,
|
||||||
|
PatientId = simPatient.Id,
|
||||||
|
TotalScore = 2,
|
||||||
|
CalculatedAt = DateTimeOffset.UtcNow,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
db.QsofaEvaluations.Add(new QsofaEvaluation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
EncounterId = simEncounter.Id,
|
||||||
|
PatientId = simPatient.Id,
|
||||||
|
ActiveCriteria = 1,
|
||||||
|
EvaluatedAt = DateTimeOffset.UtcNow,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
var alert = new ClinicalAlert
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
EncounterId = simEncounter.Id,
|
||||||
|
PatientId = simPatient.Id,
|
||||||
|
AlertType = AlertType.News2Warning,
|
||||||
|
Severity = AlertSeverity.Warning,
|
||||||
|
Details = "Cascade alert",
|
||||||
|
Status = AlertStatus.Acknowledged,
|
||||||
|
TriggeredAt = DateTimeOffset.UtcNow,
|
||||||
|
AcknowledgedAt = DateTimeOffset.UtcNow,
|
||||||
|
AcknowledgedBy = "nurse",
|
||||||
|
};
|
||||||
|
db.ClinicalAlerts.Add(alert);
|
||||||
|
db.AlertFeedbacks.Add(new AlertFeedback
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
AlertId = alert.Id,
|
||||||
|
UserId = Guid.NewGuid(),
|
||||||
|
FeedbackType = AlertFeedbackType.Useful,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
db.PhiAccessLogs.Add(new PhiAccessLog
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
PatientId = simPatient.Id,
|
||||||
|
AccessType = PhiAccessType.View,
|
||||||
|
AccessedAt = DateTimeOffset.UtcNow,
|
||||||
|
UserId = Guid.NewGuid(),
|
||||||
|
UserDisplayName = "nurse",
|
||||||
|
ResourcePath = "/api/v1/patients/" + simPatient.Id,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp = await _client.DeleteAsync("/api/v1/simulation/data");
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
using (var scope = _fixture.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
(await db.Patients.AnyAsync(p => p.Id == simPatientId)).Should().BeFalse();
|
||||||
|
(await db.Encounters.AnyAsync(e => e.Id == simEncounterId)).Should().BeFalse();
|
||||||
|
(await db.Observations.AnyAsync(o => o.EncounterId == simEncounterId)).Should().BeFalse();
|
||||||
|
(await db.News2Scores.AnyAsync(s => s.EncounterId == simEncounterId)).Should().BeFalse();
|
||||||
|
(await db.GcsScores.AnyAsync(s => s.EncounterId == simEncounterId)).Should().BeFalse();
|
||||||
|
(await db.SofaScores.AnyAsync(s => s.EncounterId == simEncounterId)).Should().BeFalse();
|
||||||
|
(await db.QsofaEvaluations.AnyAsync(s => s.EncounterId == simEncounterId)).Should().BeFalse();
|
||||||
|
(await db.ClinicalAlerts.AnyAsync(a => a.PatientId == simPatientId)).Should().BeFalse();
|
||||||
|
(await db.AlertFeedbacks.CountAsync()).Should().Be(0);
|
||||||
|
(await db.PhiAccessLogs.AnyAsync(l => l.PatientId == simPatientId)).Should().BeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Purge_WithActiveRun_Returns409()
|
||||||
|
{
|
||||||
|
_clientFactory.HangOnCreate = true;
|
||||||
|
var state = await _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None);
|
||||||
|
|
||||||
|
var resp = await _client.DeleteAsync("/api/v1/simulation/data");
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
||||||
|
|
||||||
|
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
body.GetProperty("error").GetProperty("code").GetString()
|
||||||
|
.Should().Be("SIMULATION_PURGE_ACTIVE_RUNS");
|
||||||
|
|
||||||
|
_runner.Cancel(state.RunId);
|
||||||
|
await WaitForAsync(() => !_runner.HasActiveRuns());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Purge_WritesAuditLog()
|
||||||
|
{
|
||||||
|
using (var scope = _fixture.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
db.Patients.Add(new Patient
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Mrn = "MRN-SIM-AUDIT",
|
||||||
|
FirstName = "Sim",
|
||||||
|
LastName = "Audit",
|
||||||
|
DateOfBirth = new DateOnly(1990, 1, 1),
|
||||||
|
Gender = "M",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
IsSimulated = true,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp = await _client.DeleteAsync("/api/v1/simulation/data");
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
using (var scope = _fixture.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var audit = await db.ClinicalAuditLogs
|
||||||
|
.SingleAsync(l => l.Action == AuditAction.SimulationDataPurged);
|
||||||
|
audit.NewValueJson.Should().Contain("PatientsDeleted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Purge_WhenDisabled_Returns404()
|
||||||
|
{
|
||||||
|
using var factory = _fixture.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureAppConfiguration((_, config) =>
|
||||||
|
{
|
||||||
|
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Simulation:Enabled"] = "false",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
client.AsNurse();
|
||||||
|
|
||||||
|
var resp = await client.DeleteAsync("/api/v1/simulation/data");
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForAsync(Func<bool> condition, TimeSpan? timeout = null)
|
||||||
|
{
|
||||||
|
var deadline = DateTimeOffset.UtcNow + (timeout ?? TimeSpan.FromSeconds(15));
|
||||||
|
while (DateTimeOffset.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
if (condition())
|
||||||
|
return;
|
||||||
|
await Task.Delay(25);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TimeoutException("Condition was not met within the timeout.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
[Collection("Integration")]
|
||||||
|
public class SimulationSessionTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly ApiFixture _fixture;
|
||||||
|
private ISimulationRunner _runner = null!;
|
||||||
|
private TestSimulationClientFactory _clientFactory = null!;
|
||||||
|
|
||||||
|
public SimulationSessionTests(ApiFixture fixture) => _fixture = fixture;
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||||
|
await DbResetHelper.ResetAsync(db);
|
||||||
|
await DataSeeder.SeedThresholdsOnlyAsync(db, redis);
|
||||||
|
|
||||||
|
_runner = _fixture.Services.GetRequiredService<ISimulationRunner>();
|
||||||
|
_clientFactory = _fixture.SimulationClientFactory;
|
||||||
|
_clientFactory.HangOnCreate = false;
|
||||||
|
_clientFactory.FailOnCreate = false;
|
||||||
|
|
||||||
|
foreach (var run in _runner.ListRuns())
|
||||||
|
_runner.Cancel(run.RunId);
|
||||||
|
|
||||||
|
await WaitForAsync(() => !_runner.HasActiveRuns());
|
||||||
|
_runner.ClearRegistry();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisposeAsync()
|
||||||
|
{
|
||||||
|
_clientFactory.HangOnCreate = false;
|
||||||
|
_clientFactory.FailOnCreate = false;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartSession_StartsAllScenarios()
|
||||||
|
{
|
||||||
|
_clientFactory.HangOnCreate = true;
|
||||||
|
|
||||||
|
var session = await _runner.StartSessionAsync(
|
||||||
|
"session-test-two", 60, "tester", CancellationToken.None);
|
||||||
|
|
||||||
|
session.RunIds.Should().HaveCount(2);
|
||||||
|
_runner.ListRuns().Count(r =>
|
||||||
|
r.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running)
|
||||||
|
.Should().Be(2);
|
||||||
|
|
||||||
|
foreach (var runId in session.RunIds)
|
||||||
|
_runner.Cancel(runId);
|
||||||
|
|
||||||
|
await WaitForAsync(() => !_runner.HasActiveRuns());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartSession_ExceedingConcurrency_StartsNothing()
|
||||||
|
{
|
||||||
|
_clientFactory.HangOnCreate = true;
|
||||||
|
|
||||||
|
var act = () => _runner.StartSessionAsync(
|
||||||
|
"session-test-three", 60, "tester", CancellationToken.None);
|
||||||
|
|
||||||
|
var ex = await act.Should().ThrowAsync<ConflictException>();
|
||||||
|
ex.Which.ErrorCode.Should().Be("SIMULATION_CONCURRENCY_LIMIT");
|
||||||
|
|
||||||
|
_runner.ListRuns().Should().BeEmpty();
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
(await db.SimulationRuns.CountAsync()).Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartSession_TagsRunsWithSessionId()
|
||||||
|
{
|
||||||
|
_clientFactory.HangOnCreate = true;
|
||||||
|
|
||||||
|
var session = await _runner.StartSessionAsync(
|
||||||
|
"session-test-one", null, "tester", CancellationToken.None);
|
||||||
|
|
||||||
|
session.SessionId.Should().Be("session-test-one");
|
||||||
|
var run = _runner.GetRun(session.RunIds[0])!;
|
||||||
|
run.SessionId.Should().Be("session-test-one");
|
||||||
|
run.Speed.Should().Be(120); // defaultSpeed from fixture manifest
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var row = await db.SimulationRuns.SingleAsync(r => r.Id == run.RunId);
|
||||||
|
row.SessionId.Should().Be("session-test-one");
|
||||||
|
|
||||||
|
_runner.Cancel(run.RunId);
|
||||||
|
await WaitForAsync(() => !_runner.HasActiveRuns());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForAsync(Func<bool> condition, TimeSpan? timeout = null)
|
||||||
|
{
|
||||||
|
var deadline = DateTimeOffset.UtcNow + (timeout ?? TimeSpan.FromSeconds(15));
|
||||||
|
while (DateTimeOffset.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
if (condition())
|
||||||
|
return;
|
||||||
|
await Task.Delay(25);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TimeoutException("Condition was not met within the timeout.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
@@ -7,9 +8,15 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
public class AlertQualityMetricsController : ControllerBase
|
public class AlertQualityMetricsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IAlertQualityMetricsService _metrics;
|
private readonly IAlertQualityMetricsService _metrics;
|
||||||
|
private readonly SimulationOptions _simulation;
|
||||||
|
|
||||||
public AlertQualityMetricsController(IAlertQualityMetricsService metrics) =>
|
public AlertQualityMetricsController(
|
||||||
|
IAlertQualityMetricsService metrics,
|
||||||
|
IOptions<SimulationOptions> simulation)
|
||||||
|
{
|
||||||
_metrics = metrics;
|
_metrics = metrics;
|
||||||
|
_simulation = simulation.Value;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns alert quality metric snapshots for a time range, optionally filtered by alert type.
|
/// Returns alert quality metric snapshots for a time range, optionally filtered by alert type.
|
||||||
@@ -70,4 +77,67 @@ public class AlertQualityMetricsController : ControllerBase
|
|||||||
var summary = await _metrics.GetSummaryAsync(periodStart, periodEnd);
|
var summary = await _metrics.GetSummaryAsync(periodStart, periodEnd);
|
||||||
return Ok(ApiResponse<AlertQualitySummaryResponse>.Ok(summary));
|
return Ok(ApiResponse<AlertQualitySummaryResponse>.Ok(summary));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-alert feedback rows with optional scenario attribution (when simulation is enabled).
|
||||||
|
/// Filter with <c>?scenarioId=</c> to limit to one simulated scenario.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("api/v1/alerts/quality-metrics/feedback")]
|
||||||
|
[AuthorizePermission(ClinicalPermissions.AnalyticsRead)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> ListFeedback(
|
||||||
|
[FromQuery] string? scenarioId,
|
||||||
|
[FromQuery] DateTimeOffset? from,
|
||||||
|
[FromQuery] DateTimeOffset? to)
|
||||||
|
{
|
||||||
|
var periodEnd = to ?? DateTimeOffset.UtcNow;
|
||||||
|
var periodStart = from ?? periodEnd.AddDays(-7);
|
||||||
|
if (periodStart >= periodEnd)
|
||||||
|
{
|
||||||
|
return BadRequest(ApiResponse<object>.Fail(
|
||||||
|
400, "'from' must be before 'to'.", "INVALID_DATE_RANGE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario filter only applies when simulation attribution is available.
|
||||||
|
var filterScenario = _simulation.Enabled ? scenarioId : null;
|
||||||
|
var rows = await _metrics.ListFeedbackAsync(periodStart, periodEnd, filterScenario);
|
||||||
|
|
||||||
|
if (_simulation.Enabled)
|
||||||
|
{
|
||||||
|
var withAttribution = rows.Select(r => new
|
||||||
|
{
|
||||||
|
id = r.Id,
|
||||||
|
alertId = r.AlertId,
|
||||||
|
alertType = r.AlertType,
|
||||||
|
feedbackType = r.FeedbackType,
|
||||||
|
comment = r.Comment,
|
||||||
|
createdAt = r.CreatedAt,
|
||||||
|
scenarioId = r.ScenarioId,
|
||||||
|
sessionId = r.SessionId,
|
||||||
|
});
|
||||||
|
return Ok(ApiResponse<object>.Ok(new
|
||||||
|
{
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
items = withAttribution,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
var withoutAttribution = rows.Select(r => new
|
||||||
|
{
|
||||||
|
id = r.Id,
|
||||||
|
alertId = r.AlertId,
|
||||||
|
alertType = r.AlertType,
|
||||||
|
feedbackType = r.FeedbackType,
|
||||||
|
comment = r.Comment,
|
||||||
|
createdAt = r.CreatedAt,
|
||||||
|
});
|
||||||
|
return Ok(ApiResponse<object>.Ok(new
|
||||||
|
{
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
items = withoutAttribution,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,8 @@ public class SimulationController : ControllerBase
|
|||||||
private readonly SimulationOptions _options;
|
private readonly SimulationOptions _options;
|
||||||
private readonly ISimulationRunner? _runner;
|
private readonly ISimulationRunner? _runner;
|
||||||
private readonly IScenarioCatalog? _catalog;
|
private readonly IScenarioCatalog? _catalog;
|
||||||
|
private readonly ISessionCatalog? _sessions;
|
||||||
|
private readonly ISimulationPurgeService? _purge;
|
||||||
private readonly ICurrentUserService _currentUser;
|
private readonly ICurrentUserService _currentUser;
|
||||||
private readonly IAuditService _audit;
|
private readonly IAuditService _audit;
|
||||||
|
|
||||||
@@ -26,6 +28,8 @@ public class SimulationController : ControllerBase
|
|||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
_runner = services.GetService<ISimulationRunner>();
|
_runner = services.GetService<ISimulationRunner>();
|
||||||
_catalog = services.GetService<IScenarioCatalog>();
|
_catalog = services.GetService<IScenarioCatalog>();
|
||||||
|
_sessions = services.GetService<ISessionCatalog>();
|
||||||
|
_purge = services.GetService<ISimulationPurgeService>();
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_audit = audit;
|
_audit = audit;
|
||||||
}
|
}
|
||||||
@@ -59,19 +63,75 @@ public class SimulationController : ControllerBase
|
|||||||
{
|
{
|
||||||
EnsureEnabled();
|
EnsureEnabled();
|
||||||
var items = _catalog!.ListScenarios()
|
var items = _catalog!.ListScenarios()
|
||||||
.Select(s => new ScenarioSummaryResponse(
|
.Select(ToScenarioSummary)
|
||||||
s.Scenario.Id,
|
|
||||||
s.Scenario.Name,
|
|
||||||
s.Scenario.Description,
|
|
||||||
s.Scenario.DurationMinutes,
|
|
||||||
s.Scenario.Tags,
|
|
||||||
s.Encounter.Department,
|
|
||||||
s.Events.Count,
|
|
||||||
s.ExpectedOutcomes?.Count ?? 0))
|
|
||||||
.ToList();
|
.ToList();
|
||||||
return Ok(ApiResponse<List<ScenarioSummaryResponse>>.Ok(items));
|
return Ok(ApiResponse<List<ScenarioSummaryResponse>>.Ok(items));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lists session presets with resolved scenario summaries.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("sessions")]
|
||||||
|
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<List<SessionPresetResponse>>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
public IActionResult ListSessions()
|
||||||
|
{
|
||||||
|
EnsureEnabled();
|
||||||
|
var items = _sessions!.ListSessions()
|
||||||
|
.Select(preset => new SessionPresetResponse(
|
||||||
|
preset.Id,
|
||||||
|
preset.Name,
|
||||||
|
preset.Goal,
|
||||||
|
preset.EstimatedMinutes,
|
||||||
|
preset.DefaultSpeed,
|
||||||
|
preset.Scenarios
|
||||||
|
.Select(id => _catalog!.GetById(id))
|
||||||
|
.Where(s => s is not null)
|
||||||
|
.Select(s => ToScenarioSummary(s!))
|
||||||
|
.ToList()))
|
||||||
|
.ToList();
|
||||||
|
return Ok(ApiResponse<List<SessionPresetResponse>>.Ok(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts every scenario in a session preset (all-or-nothing admission, staggered launch).
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("sessions/{sessionId}/start")]
|
||||||
|
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<SimulationSessionResponse>), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<IActionResult> StartSession(
|
||||||
|
string sessionId,
|
||||||
|
[FromBody] StartSimulationSessionRequest? req,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
EnsureEnabled();
|
||||||
|
|
||||||
|
var userId = _currentUser.UserId?.ToString()
|
||||||
|
?? throw new ValidationException("Authenticated user id is required.", "SIMULATION_USER_REQUIRED");
|
||||||
|
|
||||||
|
var state = await _runner!.StartSessionAsync(sessionId, req?.Speed, userId, ct);
|
||||||
|
|
||||||
|
await _audit.WriteAsync(
|
||||||
|
AuditAction.SimulationRunStarted,
|
||||||
|
"SimulationSession",
|
||||||
|
Guid.NewGuid(),
|
||||||
|
newValue: new
|
||||||
|
{
|
||||||
|
state.SessionId,
|
||||||
|
state.Name,
|
||||||
|
state.RunIds,
|
||||||
|
Speed = req?.Speed,
|
||||||
|
StartedBy = userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return StatusCode(201, ApiResponse<SimulationSessionResponse>.Created(
|
||||||
|
new SimulationSessionResponse(state.SessionId, state.Name, state.StartedAt, state.RunIds)));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Starts a background scenario replay.
|
/// Starts a background scenario replay.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -166,12 +226,64 @@ public class SimulationController : ControllerBase
|
|||||||
return Ok(ApiResponse<SimulationRunResponse>.Ok(ToResponse(state)));
|
return Ok(ApiResponse<SimulationRunResponse>.Ok(ToResponse(state)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Counts simulated patients and dependents currently on the ward.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("data/summary")]
|
||||||
|
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<SimulationDataSummaryResponse>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetDataSummary(CancellationToken ct)
|
||||||
|
{
|
||||||
|
EnsureEnabled();
|
||||||
|
var summary = await _purge!.GetSummaryAsync(ct);
|
||||||
|
return Ok(ApiResponse<SimulationDataSummaryResponse>.Ok(
|
||||||
|
new SimulationDataSummaryResponse(
|
||||||
|
summary.SimulatedPatients,
|
||||||
|
summary.Encounters,
|
||||||
|
summary.Observations,
|
||||||
|
summary.Alerts,
|
||||||
|
summary.ActiveRuns)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Purges all simulated patients and dependents. Refuses while runs are active.
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("data")]
|
||||||
|
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<SimulationPurgeResponse>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> PurgeData(CancellationToken ct)
|
||||||
|
{
|
||||||
|
EnsureEnabled();
|
||||||
|
var result = await _purge!.PurgeAsync(ct);
|
||||||
|
return Ok(ApiResponse<SimulationPurgeResponse>.Ok(
|
||||||
|
new SimulationPurgeResponse(
|
||||||
|
result.PatientsDeleted,
|
||||||
|
result.EncountersDeleted,
|
||||||
|
result.AlertsDeleted,
|
||||||
|
result.RunsCleared)));
|
||||||
|
}
|
||||||
|
|
||||||
private void EnsureEnabled()
|
private void EnsureEnabled()
|
||||||
{
|
{
|
||||||
if (!_options.Enabled || _runner is null || _catalog is null)
|
if (!_options.Enabled || _runner is null || _catalog is null
|
||||||
|
|| _sessions is null || _purge is null)
|
||||||
throw new NotFoundException("Simulation endpoints are not available.");
|
throw new NotFoundException("Simulation endpoints are not available.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ScenarioSummaryResponse ToScenarioSummary(VigilCare.Simulation.ScenarioFile s) =>
|
||||||
|
new(
|
||||||
|
s.Scenario.Id,
|
||||||
|
s.Scenario.Name,
|
||||||
|
s.Scenario.Description,
|
||||||
|
s.Scenario.DurationMinutes,
|
||||||
|
s.Scenario.Tags,
|
||||||
|
s.Encounter.Department,
|
||||||
|
s.Events.Count,
|
||||||
|
s.ExpectedOutcomes?.Count ?? 0);
|
||||||
|
|
||||||
private static SimulationRunResponse ToResponse(SimulationRunState state) =>
|
private static SimulationRunResponse ToResponse(SimulationRunState state) =>
|
||||||
new(
|
new(
|
||||||
state.RunId,
|
state.RunId,
|
||||||
@@ -181,6 +293,7 @@ public class SimulationController : ControllerBase
|
|||||||
state.Speed,
|
state.Speed,
|
||||||
state.PatientId,
|
state.PatientId,
|
||||||
state.EncounterId,
|
state.EncounterId,
|
||||||
|
state.SessionId,
|
||||||
state.PatientDisplayName,
|
state.PatientDisplayName,
|
||||||
state.StartedAt,
|
state.StartedAt,
|
||||||
state.ElapsedRealSeconds,
|
state.ElapsedRealSeconds,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public class SimulationRunConfiguration : IEntityTypeConfiguration<SimulationRun
|
|||||||
v => SimulationRunStatusExtensions.FromDbString(v));
|
v => SimulationRunStatusExtensions.FromDbString(v));
|
||||||
builder.Property(r => r.PatientId).HasColumnName("patient_id");
|
builder.Property(r => r.PatientId).HasColumnName("patient_id");
|
||||||
builder.Property(r => r.EncounterId).HasColumnName("encounter_id");
|
builder.Property(r => r.EncounterId).HasColumnName("encounter_id");
|
||||||
|
builder.Property(r => r.SessionId).HasColumnName("session_id").HasMaxLength(100);
|
||||||
builder.Property(r => r.StartedByUserId).HasColumnName("started_by_user_id").HasMaxLength(100).IsRequired();
|
builder.Property(r => r.StartedByUserId).HasColumnName("started_by_user_id").HasMaxLength(100).IsRequired();
|
||||||
builder.Property(r => r.StartedAt).HasColumnName("started_at");
|
builder.Property(r => r.StartedAt).HasColumnName("started_at");
|
||||||
builder.Property(r => r.CompletedAt).HasColumnName("completed_at");
|
builder.Property(r => r.CompletedAt).HasColumnName("completed_at");
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ public class SimulationRun
|
|||||||
public SimulationRunStatus Status { get; set; }
|
public SimulationRunStatus Status { get; set; }
|
||||||
public Guid? PatientId { get; set; }
|
public Guid? PatientId { get; set; }
|
||||||
public Guid? EncounterId { get; set; }
|
public Guid? EncounterId { get; set; }
|
||||||
|
/// <summary>Session preset id when started via a Phase 38 session; null for single-scenario starts.</summary>
|
||||||
|
public string? SessionId { get; set; }
|
||||||
public string StartedByUserId { get; set; } = null!;
|
public string StartedByUserId { get; set; } = null!;
|
||||||
public DateTimeOffset StartedAt { get; set; }
|
public DateTimeOffset StartedAt { get; set; }
|
||||||
public DateTimeOffset? CompletedAt { get; set; }
|
public DateTimeOffset? CompletedAt { get; set; }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public enum AuditAction
|
|||||||
TokenRefreshed,
|
TokenRefreshed,
|
||||||
SimulationRunStarted,
|
SimulationRunStarted,
|
||||||
SimulationRunStopped,
|
SimulationRunStopped,
|
||||||
|
SimulationDataPurged,
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class AuditActionExtensions
|
public static class AuditActionExtensions
|
||||||
@@ -38,6 +39,7 @@ public static class AuditActionExtensions
|
|||||||
AuditAction.TokenRefreshed => "TOKEN_REFRESHED",
|
AuditAction.TokenRefreshed => "TOKEN_REFRESHED",
|
||||||
AuditAction.SimulationRunStarted => "SIMULATION_RUN_STARTED",
|
AuditAction.SimulationRunStarted => "SIMULATION_RUN_STARTED",
|
||||||
AuditAction.SimulationRunStopped => "SIMULATION_RUN_STOPPED",
|
AuditAction.SimulationRunStopped => "SIMULATION_RUN_STOPPED",
|
||||||
|
AuditAction.SimulationDataPurged => "SIMULATION_DATA_PURGED",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(a))
|
_ => throw new ArgumentOutOfRangeException(nameof(a))
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,6 +61,7 @@ public static class AuditActionExtensions
|
|||||||
"TOKEN_REFRESHED" => AuditAction.TokenRefreshed,
|
"TOKEN_REFRESHED" => AuditAction.TokenRefreshed,
|
||||||
"SIMULATION_RUN_STARTED" => AuditAction.SimulationRunStarted,
|
"SIMULATION_RUN_STARTED" => AuditAction.SimulationRunStarted,
|
||||||
"SIMULATION_RUN_STOPPED" => AuditAction.SimulationRunStopped,
|
"SIMULATION_RUN_STOPPED" => AuditAction.SimulationRunStopped,
|
||||||
|
"SIMULATION_DATA_PURGED" => AuditAction.SimulationDataPurged,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(v))
|
_ => throw new ArgumentOutOfRangeException(nameof(v))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+2024
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace VigilCareClinicalAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSimulationSessionId : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "session_id",
|
||||||
|
table: "simulation_runs",
|
||||||
|
type: "character varying(100)",
|
||||||
|
maxLength: 100,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "session_id",
|
||||||
|
table: "simulation_runs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1587,6 +1587,11 @@ namespace VigilCareClinicalAPI.Migrations
|
|||||||
.HasColumnType("character varying(200)")
|
.HasColumnType("character varying(200)")
|
||||||
.HasColumnName("scenario_name");
|
.HasColumnName("scenario_name");
|
||||||
|
|
||||||
|
b.Property<string>("SessionId")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)")
|
||||||
|
.HasColumnName("session_id");
|
||||||
|
|
||||||
b.Property<double>("Speed")
|
b.Property<double>("Speed")
|
||||||
.HasColumnType("double precision")
|
.HasColumnType("double precision")
|
||||||
.HasColumnName("speed");
|
.HasColumnName("speed");
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
public record AlertFeedbackQualityRow(
|
||||||
|
Guid Id,
|
||||||
|
Guid AlertId,
|
||||||
|
string AlertType,
|
||||||
|
string FeedbackType,
|
||||||
|
string? Comment,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
string? ScenarioId,
|
||||||
|
string? SessionId);
|
||||||
@@ -15,6 +15,35 @@ public record ScenarioSummaryResponse(
|
|||||||
|
|
||||||
public record StartSimulationRunRequest(string ScenarioId, double Speed = 60);
|
public record StartSimulationRunRequest(string ScenarioId, double Speed = 60);
|
||||||
|
|
||||||
|
public record StartSimulationSessionRequest(double? Speed = null);
|
||||||
|
|
||||||
|
public record SessionPresetResponse(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string Goal,
|
||||||
|
int EstimatedMinutes,
|
||||||
|
double DefaultSpeed,
|
||||||
|
IReadOnlyList<ScenarioSummaryResponse> Scenarios);
|
||||||
|
|
||||||
|
public record SimulationSessionResponse(
|
||||||
|
string SessionId,
|
||||||
|
string Name,
|
||||||
|
DateTimeOffset StartedAt,
|
||||||
|
IReadOnlyList<Guid> RunIds);
|
||||||
|
|
||||||
|
public record SimulationDataSummaryResponse(
|
||||||
|
int SimulatedPatients,
|
||||||
|
int Encounters,
|
||||||
|
int Observations,
|
||||||
|
int Alerts,
|
||||||
|
int ActiveRuns);
|
||||||
|
|
||||||
|
public record SimulationPurgeResponse(
|
||||||
|
int PatientsDeleted,
|
||||||
|
int EncountersDeleted,
|
||||||
|
int AlertsDeleted,
|
||||||
|
int RunsCleared);
|
||||||
|
|
||||||
public record SimulationRunResponse(
|
public record SimulationRunResponse(
|
||||||
Guid RunId,
|
Guid RunId,
|
||||||
string ScenarioId,
|
string ScenarioId,
|
||||||
@@ -23,6 +52,7 @@ public record SimulationRunResponse(
|
|||||||
double Speed,
|
double Speed,
|
||||||
Guid? PatientId,
|
Guid? PatientId,
|
||||||
Guid? EncounterId,
|
Guid? EncounterId,
|
||||||
|
string? SessionId,
|
||||||
string PatientDisplayName,
|
string PatientDisplayName,
|
||||||
DateTimeOffset StartedAt,
|
DateTimeOffset StartedAt,
|
||||||
double ElapsedRealSeconds,
|
double ElapsedRealSeconds,
|
||||||
|
|||||||
@@ -179,9 +179,11 @@ try
|
|||||||
c.BaseAddress = new Uri(simulationOptions.LoopbackBaseUrl));
|
c.BaseAddress = new Uri(simulationOptions.LoopbackBaseUrl));
|
||||||
builder.Services.AddSingleton<ISimulationClientFactory, SimulationClientFactory>();
|
builder.Services.AddSingleton<ISimulationClientFactory, SimulationClientFactory>();
|
||||||
builder.Services.AddSingleton<IScenarioCatalog, ScenarioCatalog>();
|
builder.Services.AddSingleton<IScenarioCatalog, ScenarioCatalog>();
|
||||||
|
builder.Services.AddSingleton<ISessionCatalog, SessionCatalog>();
|
||||||
builder.Services.AddSingleton<SimulationRunner>();
|
builder.Services.AddSingleton<SimulationRunner>();
|
||||||
builder.Services.AddSingleton<ISimulationRunner>(sp => sp.GetRequiredService<SimulationRunner>());
|
builder.Services.AddSingleton<ISimulationRunner>(sp => sp.GetRequiredService<SimulationRunner>());
|
||||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<SimulationRunner>());
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<SimulationRunner>());
|
||||||
|
builder.Services.AddScoped<ISimulationPurgeService, SimulationPurgeService>();
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
|
|||||||
@@ -63,6 +63,51 @@ public class AlertQualityMetricsService : IAlertQualityMetricsService
|
|||||||
: weightedResolveSeconds / snapshots.Sum(s => s.ResolvedCount));
|
: weightedResolveSeconds / snapshots.Sum(s => s.ResolvedCount));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<AlertFeedbackQualityRow>> ListFeedbackAsync(
|
||||||
|
DateTimeOffset periodStart, DateTimeOffset periodEnd, string? scenarioId)
|
||||||
|
{
|
||||||
|
// Left-join ClinicalAlert → SimulationRun on EncounterId for attribution.
|
||||||
|
var query =
|
||||||
|
from f in _db.AlertFeedbacks.AsNoTracking()
|
||||||
|
join a in _db.ClinicalAlerts.AsNoTracking() on f.AlertId equals a.Id
|
||||||
|
join r in _db.SimulationRuns.AsNoTracking()
|
||||||
|
on a.EncounterId equals r.EncounterId into runs
|
||||||
|
from r in runs.DefaultIfEmpty()
|
||||||
|
where f.CreatedAt >= periodStart && f.CreatedAt <= periodEnd
|
||||||
|
select new { Feedback = f, Alert = a, Run = r };
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(scenarioId))
|
||||||
|
{
|
||||||
|
var needle = scenarioId.ToLowerInvariant();
|
||||||
|
query = query.Where(x =>
|
||||||
|
x.Run != null
|
||||||
|
&& x.Run.ScenarioId != null
|
||||||
|
&& x.Run.ScenarioId.ToLower() == needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = await query
|
||||||
|
.OrderByDescending(x => x.Feedback.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Distinct by feedback id in case multiple SimulationRun rows share an EncounterId.
|
||||||
|
return rows
|
||||||
|
.GroupBy(x => x.Feedback.Id)
|
||||||
|
.Select(g =>
|
||||||
|
{
|
||||||
|
var x = g.First();
|
||||||
|
return new AlertFeedbackQualityRow(
|
||||||
|
x.Feedback.Id,
|
||||||
|
x.Alert.Id,
|
||||||
|
x.Alert.AlertType.ToDbString(),
|
||||||
|
x.Feedback.FeedbackType.ToDbString(),
|
||||||
|
x.Feedback.Comment,
|
||||||
|
x.Feedback.CreatedAt,
|
||||||
|
x.Run?.ScenarioId,
|
||||||
|
x.Run?.SessionId);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
private static AlertQualityMetricResponse Map(AlertQualityMetric m) =>
|
private static AlertQualityMetricResponse Map(AlertQualityMetric m) =>
|
||||||
new(
|
new(
|
||||||
m.Id,
|
m.Id,
|
||||||
|
|||||||
@@ -5,4 +5,12 @@ public interface IAlertQualityMetricsService
|
|||||||
|
|
||||||
Task<AlertQualitySummaryResponse> GetSummaryAsync(
|
Task<AlertQualitySummaryResponse> GetSummaryAsync(
|
||||||
DateTimeOffset from, DateTimeOffset to);
|
DateTimeOffset from, DateTimeOffset to);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-alert feedback rows for analytics export / by-scenario breakdown.
|
||||||
|
/// When <paramref name="scenarioId"/> is set, only rows attributed to that
|
||||||
|
/// simulation scenario are returned (join via SimulationRun.EncounterId).
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<AlertFeedbackQualityRow>> ListFeedbackAsync(
|
||||||
|
DateTimeOffset periodStart, DateTimeOffset periodEnd, string? scenarioId);
|
||||||
}
|
}
|
||||||
@@ -44,8 +44,12 @@ public sealed class ScenarioCatalog : IScenarioCatalog
|
|||||||
}
|
}
|
||||||
|
|
||||||
var disk = Directory.EnumerateFiles(_directory, "*.json")
|
var disk = Directory.EnumerateFiles(_directory, "*.json")
|
||||||
.Where(p => !string.Equals(
|
.Where(p =>
|
||||||
Path.GetFileName(p), "schema.json", StringComparison.OrdinalIgnoreCase))
|
{
|
||||||
|
var name = Path.GetFileName(p);
|
||||||
|
return !string.Equals(name, "schema.json", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(name, "sessions.json", StringComparison.OrdinalIgnoreCase);
|
||||||
|
})
|
||||||
.Select(p => (Path: p, LastWriteUtc: File.GetLastWriteTimeUtc(p)))
|
.Select(p => (Path: p, LastWriteUtc: File.GetLastWriteTimeUtc(p)))
|
||||||
.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
public interface ISessionCatalog
|
||||||
|
{
|
||||||
|
IReadOnlyList<SessionPreset> ListSessions();
|
||||||
|
SessionPreset? GetById(string sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SessionPreset(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string Goal,
|
||||||
|
int EstimatedMinutes,
|
||||||
|
double DefaultSpeed,
|
||||||
|
IReadOnlyList<string> Scenarios);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads <c>sessions.json</c> from the scenario directory (or its parent) and
|
||||||
|
/// validates every referenced scenario id against <see cref="IScenarioCatalog"/>
|
||||||
|
/// at construction — a typo fails loudly at startup.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SessionCatalog : ISessionCatalog
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly IReadOnlyList<SessionPreset> _sessions;
|
||||||
|
|
||||||
|
public SessionCatalog(IOptions<SimulationOptions> options, IScenarioCatalog scenarios)
|
||||||
|
{
|
||||||
|
var directory = options.Value.ScenarioDirectory;
|
||||||
|
var path = ResolveManifestPath(directory)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"Simulation session manifest not found. Expected sessions.json in or beside " +
|
||||||
|
$"ScenarioDirectory '{Path.GetFullPath(directory)}'.");
|
||||||
|
|
||||||
|
_sessions = LoadAndValidate(path, scenarios);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<SessionPreset> ListSessions() => _sessions;
|
||||||
|
|
||||||
|
public SessionPreset? GetById(string sessionId) =>
|
||||||
|
_sessions.FirstOrDefault(s =>
|
||||||
|
string.Equals(s.Id, sessionId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
internal static string? ResolveManifestPath(string scenarioDirectory)
|
||||||
|
{
|
||||||
|
var inDir = Path.Combine(scenarioDirectory, "sessions.json");
|
||||||
|
if (File.Exists(inDir))
|
||||||
|
return inDir;
|
||||||
|
|
||||||
|
var parent = Directory.GetParent(Path.GetFullPath(scenarioDirectory))?.FullName;
|
||||||
|
if (parent is not null)
|
||||||
|
{
|
||||||
|
var beside = Path.Combine(parent, "sessions.json");
|
||||||
|
if (File.Exists(beside))
|
||||||
|
return beside;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<SessionPreset> LoadAndValidate(
|
||||||
|
string path, IScenarioCatalog scenarios)
|
||||||
|
{
|
||||||
|
SessionsDocument doc;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(path);
|
||||||
|
doc = JsonSerializer.Deserialize<SessionsDocument>(json, JsonOptions)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"Session manifest '{path}' deserialized to null.");
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not InvalidOperationException)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Failed to load simulation session manifest '{path}': {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doc.Sessions is null || doc.Sessions.Count == 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Session manifest '{path}' contains no sessions.");
|
||||||
|
|
||||||
|
var presets = new List<SessionPreset>(doc.Sessions.Count);
|
||||||
|
var missing = new List<string>();
|
||||||
|
|
||||||
|
foreach (var entry in doc.Sessions)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(entry.Id))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Session manifest '{path}' has a session with an empty id.");
|
||||||
|
|
||||||
|
if (entry.Scenarios is null || entry.Scenarios.Count == 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Session '{entry.Id}' in '{path}' lists no scenarios.");
|
||||||
|
|
||||||
|
foreach (var scenarioId in entry.Scenarios)
|
||||||
|
{
|
||||||
|
if (scenarios.GetById(scenarioId) is null)
|
||||||
|
missing.Add($"{entry.Id} → {scenarioId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
presets.Add(new SessionPreset(
|
||||||
|
entry.Id,
|
||||||
|
entry.Name ?? entry.Id,
|
||||||
|
entry.Goal ?? string.Empty,
|
||||||
|
entry.EstimatedMinutes,
|
||||||
|
entry.DefaultSpeed <= 0 ? 60 : entry.DefaultSpeed,
|
||||||
|
entry.Scenarios.AsReadOnly()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing.Count > 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Session manifest references unknown scenario id(s): " +
|
||||||
|
string.Join("; ", missing));
|
||||||
|
}
|
||||||
|
|
||||||
|
return presets;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SessionsDocument
|
||||||
|
{
|
||||||
|
public List<SessionEntry>? Sessions { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SessionEntry
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = null!;
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? Goal { get; set; }
|
||||||
|
public int EstimatedMinutes { get; set; }
|
||||||
|
public double DefaultSpeed { get; set; }
|
||||||
|
public List<string>? Scenarios { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
public interface ISimulationPurgeService
|
||||||
|
{
|
||||||
|
Task<SimulationDataSummary> GetSummaryAsync(CancellationToken ct);
|
||||||
|
Task<SimulationPurgeResult> PurgeAsync(CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SimulationDataSummary(
|
||||||
|
int SimulatedPatients,
|
||||||
|
int Encounters,
|
||||||
|
int Observations,
|
||||||
|
int Alerts,
|
||||||
|
int ActiveRuns);
|
||||||
|
|
||||||
|
public record SimulationPurgeResult(
|
||||||
|
int PatientsDeleted,
|
||||||
|
int EncountersDeleted,
|
||||||
|
int AlertsDeleted,
|
||||||
|
int RunsCleared);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes all patients with <c>IsSimulated = true</c> and their dependents.
|
||||||
|
/// Never deletes by encounter or date range — only the simulated-patient predicate.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SimulationPurgeService : ISimulationPurgeService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ISimulationRunner _runner;
|
||||||
|
private readonly IAuditService _audit;
|
||||||
|
|
||||||
|
public SimulationPurgeService(
|
||||||
|
AppDbContext db,
|
||||||
|
ISimulationRunner runner,
|
||||||
|
IAuditService audit)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_runner = runner;
|
||||||
|
_audit = audit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SimulationDataSummary> GetSummaryAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var patientIds = await _db.Patients
|
||||||
|
.Where(p => p.IsSimulated)
|
||||||
|
.Select(p => p.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (patientIds.Count == 0)
|
||||||
|
{
|
||||||
|
return new SimulationDataSummary(
|
||||||
|
0, 0, 0, 0,
|
||||||
|
_runner.ListRuns().Count(r =>
|
||||||
|
r.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running));
|
||||||
|
}
|
||||||
|
|
||||||
|
var encounterIds = await _db.Encounters
|
||||||
|
.Where(e => patientIds.Contains(e.PatientId))
|
||||||
|
.Select(e => e.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var observations = encounterIds.Count == 0
|
||||||
|
? 0
|
||||||
|
: await _db.Observations.CountAsync(o => encounterIds.Contains(o.EncounterId), ct);
|
||||||
|
|
||||||
|
var alerts = await _db.ClinicalAlerts
|
||||||
|
.CountAsync(a => patientIds.Contains(a.PatientId), ct);
|
||||||
|
|
||||||
|
var activeRuns = _runner.ListRuns().Count(r =>
|
||||||
|
r.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running);
|
||||||
|
|
||||||
|
return new SimulationDataSummary(
|
||||||
|
patientIds.Count,
|
||||||
|
encounterIds.Count,
|
||||||
|
observations,
|
||||||
|
alerts,
|
||||||
|
activeRuns);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SimulationPurgeResult> PurgeAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_runner.HasActiveRuns())
|
||||||
|
throw new ConflictException(
|
||||||
|
"Cannot purge simulated data while runs are Pending or Running. Stop all runs first.",
|
||||||
|
"SIMULATION_PURGE_ACTIVE_RUNS");
|
||||||
|
|
||||||
|
await using var tx = await _db.Database.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
var patientIds = await _db.Patients
|
||||||
|
.Where(p => p.IsSimulated)
|
||||||
|
.Select(p => p.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (patientIds.Count == 0)
|
||||||
|
{
|
||||||
|
var emptyRunsCleared = await ClearSimulationRunsAsync(ct);
|
||||||
|
await tx.CommitAsync(ct);
|
||||||
|
_runner.ClearRegistry();
|
||||||
|
|
||||||
|
var emptyResult = new SimulationPurgeResult(0, 0, 0, emptyRunsCleared);
|
||||||
|
await WriteAuditAsync(emptyResult);
|
||||||
|
return emptyResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
var encounterIds = await _db.Encounters
|
||||||
|
.Where(e => patientIds.Contains(e.PatientId))
|
||||||
|
.Select(e => e.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var alertIds = await _db.ClinicalAlerts
|
||||||
|
.Where(a => patientIds.Contains(a.PatientId))
|
||||||
|
.Select(a => a.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var alertsDeleted = alertIds.Count;
|
||||||
|
var encountersDeleted = encounterIds.Count;
|
||||||
|
var patientsDeleted = patientIds.Count;
|
||||||
|
|
||||||
|
// FK order — Restrict relationships require dependents first.
|
||||||
|
if (encounterIds.Count > 0)
|
||||||
|
{
|
||||||
|
await _db.MedicationAdministrations
|
||||||
|
.Where(m => encounterIds.Contains(m.EncounterId))
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
var bundleIds = await _db.SepsisBundles
|
||||||
|
.Where(b => encounterIds.Contains(b.EncounterId))
|
||||||
|
.Select(b => b.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (bundleIds.Count > 0)
|
||||||
|
{
|
||||||
|
await _db.SepsisBundleElements
|
||||||
|
.Where(e => bundleIds.Contains(e.BundleId))
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
await _db.SepsisBundles
|
||||||
|
.Where(b => bundleIds.Contains(b.Id))
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.Orders
|
||||||
|
.Where(o => encounterIds.Contains(o.EncounterId))
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
await _db.ReconciliationAlerts
|
||||||
|
.Where(r => r.EncounterId != null && encounterIds.Contains(r.EncounterId.Value))
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
var encounterKeys = encounterIds.Select(id => id.ToString()).ToList();
|
||||||
|
await _db.OutboxEvents
|
||||||
|
.Where(o => o.PartitionKey != null && encounterKeys.Contains(o.PartitionKey))
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
await _db.Observations
|
||||||
|
.Where(o => encounterIds.Contains(o.EncounterId))
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
await _db.News2Scores
|
||||||
|
.Where(s => encounterIds.Contains(s.EncounterId))
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
await _db.SofaScores
|
||||||
|
.Where(s => encounterIds.Contains(s.EncounterId))
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
await _db.GcsScores
|
||||||
|
.Where(s => encounterIds.Contains(s.EncounterId))
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
await _db.QsofaEvaluations
|
||||||
|
.Where(s => encounterIds.Contains(s.EncounterId))
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alertIds.Count > 0)
|
||||||
|
{
|
||||||
|
// AlertFeedback cascades with alert, but delete explicitly for clarity.
|
||||||
|
await _db.AlertFeedbacks
|
||||||
|
.Where(f => alertIds.Contains(f.AlertId))
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
await _db.ClinicalAlerts
|
||||||
|
.Where(a => alertIds.Contains(a.Id))
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window aggregates are not patient-scoped; clear them so the next tester
|
||||||
|
// starts with a clean quality dashboard after a ward reset.
|
||||||
|
await _db.AlertQualityMetrics.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
await _db.PhiAccessLogs
|
||||||
|
.Where(l => l.PatientId != null && patientIds.Contains(l.PatientId.Value))
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
var resourceIds = patientIds.Concat(encounterIds).ToList();
|
||||||
|
if (resourceIds.Count > 0)
|
||||||
|
{
|
||||||
|
await _db.ExternalResourceIdentifiers
|
||||||
|
.Where(x => resourceIds.Contains(x.InternalId))
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (encounterIds.Count > 0)
|
||||||
|
{
|
||||||
|
await _db.Encounters
|
||||||
|
.Where(e => encounterIds.Contains(e.Id))
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.Patients
|
||||||
|
.Where(p => patientIds.Contains(p.Id))
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
var runsCleared = await ClearSimulationRunsAsync(ct);
|
||||||
|
|
||||||
|
await tx.CommitAsync(ct);
|
||||||
|
_runner.ClearRegistry();
|
||||||
|
|
||||||
|
var result = new SimulationPurgeResult(
|
||||||
|
patientsDeleted, encountersDeleted, alertsDeleted, runsCleared);
|
||||||
|
await WriteAuditAsync(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> ClearSimulationRunsAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var count = await _db.SimulationRuns.CountAsync(ct);
|
||||||
|
if (count > 0)
|
||||||
|
await _db.SimulationRuns.ExecuteDeleteAsync(ct);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteAuditAsync(SimulationPurgeResult result)
|
||||||
|
{
|
||||||
|
await _audit.WriteAsync(
|
||||||
|
AuditAction.SimulationDataPurged,
|
||||||
|
"SimulationData",
|
||||||
|
Guid.NewGuid(),
|
||||||
|
newValue: new
|
||||||
|
{
|
||||||
|
result.PatientsDeleted,
|
||||||
|
result.EncountersDeleted,
|
||||||
|
result.AlertsDeleted,
|
||||||
|
result.RunsCleared,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ public sealed class SimulationRunState
|
|||||||
public string ScenarioId { get; init; } = null!;
|
public string ScenarioId { get; init; } = null!;
|
||||||
public string ScenarioName { get; init; } = null!;
|
public string ScenarioName { get; init; } = null!;
|
||||||
public double Speed { get; init; }
|
public double Speed { get; init; }
|
||||||
|
public string? SessionId { get; init; }
|
||||||
public string StartedByUserId { get; init; } = null!;
|
public string StartedByUserId { get; init; } = null!;
|
||||||
public DateTimeOffset StartedAt { get; init; }
|
public DateTimeOffset StartedAt { get; init; }
|
||||||
public double TotalOffsetMinutes { get; init; }
|
public double TotalOffsetMinutes { get; init; }
|
||||||
@@ -105,6 +106,7 @@ public sealed class SimulationRunState
|
|||||||
ScenarioId = ScenarioId,
|
ScenarioId = ScenarioId,
|
||||||
ScenarioName = ScenarioName,
|
ScenarioName = ScenarioName,
|
||||||
Speed = Speed,
|
Speed = Speed,
|
||||||
|
SessionId = SessionId,
|
||||||
StartedByUserId = StartedByUserId,
|
StartedByUserId = StartedByUserId,
|
||||||
StartedAt = StartedAt,
|
StartedAt = StartedAt,
|
||||||
TotalOffsetMinutes = TotalOffsetMinutes,
|
TotalOffsetMinutes = TotalOffsetMinutes,
|
||||||
|
|||||||
@@ -8,15 +8,23 @@ public interface ISimulationRunner
|
|||||||
IReadOnlyList<SimulationRunState> ListRuns();
|
IReadOnlyList<SimulationRunState> ListRuns();
|
||||||
SimulationRunState? GetRun(Guid runId);
|
SimulationRunState? GetRun(Guid runId);
|
||||||
Task<SimulationRunState> StartAsync(
|
Task<SimulationRunState> StartAsync(
|
||||||
string scenarioId, double speed, string startedByUserId, CancellationToken ct);
|
string scenarioId, double speed, string startedByUserId, CancellationToken ct,
|
||||||
|
string? sessionId = null);
|
||||||
|
Task<SimulationSessionState> StartSessionAsync(
|
||||||
|
string sessionId, double? speed, string userId, CancellationToken ct);
|
||||||
bool Cancel(Guid runId);
|
bool Cancel(Guid runId);
|
||||||
|
bool HasActiveRuns();
|
||||||
|
void ClearRegistry();
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
||||||
{
|
{
|
||||||
|
private static readonly TimeSpan SessionStagger = TimeSpan.FromMilliseconds(500);
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<Guid, RunContext> _runs = new();
|
private readonly ConcurrentDictionary<Guid, RunContext> _runs = new();
|
||||||
private readonly ISimulationClientFactory _clientFactory;
|
private readonly ISimulationClientFactory _clientFactory;
|
||||||
private readonly IScenarioCatalog _catalog;
|
private readonly IScenarioCatalog _catalog;
|
||||||
|
private readonly ISessionCatalog _sessions;
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
private readonly SimulationOptions _options;
|
private readonly SimulationOptions _options;
|
||||||
private readonly ILogger<SimulationRunner> _logger;
|
private readonly ILogger<SimulationRunner> _logger;
|
||||||
@@ -24,12 +32,14 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
|||||||
public SimulationRunner(
|
public SimulationRunner(
|
||||||
ISimulationClientFactory clientFactory,
|
ISimulationClientFactory clientFactory,
|
||||||
IScenarioCatalog catalog,
|
IScenarioCatalog catalog,
|
||||||
|
ISessionCatalog sessions,
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
IOptions<SimulationOptions> options,
|
IOptions<SimulationOptions> options,
|
||||||
ILogger<SimulationRunner> logger)
|
ILogger<SimulationRunner> logger)
|
||||||
{
|
{
|
||||||
_clientFactory = clientFactory;
|
_clientFactory = clientFactory;
|
||||||
_catalog = catalog;
|
_catalog = catalog;
|
||||||
|
_sessions = sessions;
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -44,8 +54,15 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
|||||||
public SimulationRunState? GetRun(Guid runId) =>
|
public SimulationRunState? GetRun(Guid runId) =>
|
||||||
_runs.TryGetValue(runId, out var ctx) ? ctx.State.Snapshot() : null;
|
_runs.TryGetValue(runId, out var ctx) ? ctx.State.Snapshot() : null;
|
||||||
|
|
||||||
|
public bool HasActiveRuns() =>
|
||||||
|
_runs.Values.Any(c =>
|
||||||
|
c.State.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running);
|
||||||
|
|
||||||
|
public void ClearRegistry() => _runs.Clear();
|
||||||
|
|
||||||
public async Task<SimulationRunState> StartAsync(
|
public async Task<SimulationRunState> StartAsync(
|
||||||
string scenarioId, double speed, string startedByUserId, CancellationToken ct)
|
string scenarioId, double speed, string startedByUserId, CancellationToken ct,
|
||||||
|
string? sessionId = null)
|
||||||
{
|
{
|
||||||
if (!_options.Enabled)
|
if (!_options.Enabled)
|
||||||
throw new ValidationException("Simulation is disabled.", "SIMULATION_DISABLED");
|
throw new ValidationException("Simulation is disabled.", "SIMULATION_DISABLED");
|
||||||
@@ -68,34 +85,77 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
|||||||
$"Maximum concurrent simulation runs ({_options.MaxConcurrentRuns}) reached.",
|
$"Maximum concurrent simulation runs ({_options.MaxConcurrentRuns}) reached.",
|
||||||
"SIMULATION_CONCURRENCY_LIMIT");
|
"SIMULATION_CONCURRENCY_LIMIT");
|
||||||
|
|
||||||
var totalOffset = scenario.Events.Count == 0
|
return await RegisterAndStartAsync(scenario, speed, startedByUserId, sessionId, ct);
|
||||||
? 0
|
}
|
||||||
: scenario.Events.Max(e => e.OffsetMinutes);
|
|
||||||
|
|
||||||
var runId = Guid.NewGuid();
|
public async Task<SimulationSessionState> StartSessionAsync(
|
||||||
var startedAt = DateTimeOffset.UtcNow;
|
string sessionId, double? speed, string userId, CancellationToken ct)
|
||||||
var state = new SimulationRunState
|
{
|
||||||
|
if (!_options.Enabled)
|
||||||
|
throw new ValidationException("Simulation is disabled.", "SIMULATION_DISABLED");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
throw new ValidationException("sessionId is required.", "SIMULATION_SESSION_REQUIRED");
|
||||||
|
|
||||||
|
var preset = _sessions.GetById(sessionId)
|
||||||
|
?? throw new ValidationException(
|
||||||
|
$"Unknown session '{sessionId}'.", "SIMULATION_SESSION_UNKNOWN");
|
||||||
|
|
||||||
|
var resolvedSpeed = speed ?? preset.DefaultSpeed;
|
||||||
|
if (resolvedSpeed <= 0 || resolvedSpeed > _options.MaxSpeed)
|
||||||
|
throw new ValidationException(
|
||||||
|
$"speed must be > 0 and <= {_options.MaxSpeed}.", "SIMULATION_SPEED_INVALID");
|
||||||
|
|
||||||
|
// Resolve every scenario up front so a typo never half-starts a session.
|
||||||
|
var scenarios = new List<ScenarioFile>(preset.Scenarios.Count);
|
||||||
|
foreach (var id in preset.Scenarios)
|
||||||
{
|
{
|
||||||
RunId = runId,
|
var scenario = _catalog.GetById(id)
|
||||||
ScenarioId = scenario.Scenario.Id,
|
?? throw new ValidationException(
|
||||||
ScenarioName = scenario.Scenario.Name,
|
$"Unknown scenario '{id}' in session '{preset.Id}'.",
|
||||||
Speed = speed,
|
"SIMULATION_SCENARIO_UNKNOWN");
|
||||||
StartedByUserId = startedByUserId,
|
scenarios.Add(scenario);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All-or-nothing admission — check capacity before starting anything.
|
||||||
|
var activeCount = _runs.Values.Count(c =>
|
||||||
|
c.State.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running);
|
||||||
|
if (activeCount + scenarios.Count > _options.MaxConcurrentRuns)
|
||||||
|
throw new ConflictException(
|
||||||
|
$"Session '{preset.Id}' needs {scenarios.Count} runs but only " +
|
||||||
|
$"{_options.MaxConcurrentRuns - activeCount} slot(s) remain " +
|
||||||
|
$"(max {_options.MaxConcurrentRuns}).",
|
||||||
|
"SIMULATION_CONCURRENCY_LIMIT");
|
||||||
|
|
||||||
|
var startedAt = DateTimeOffset.UtcNow;
|
||||||
|
var started = new List<SimulationRunState>(scenarios.Count);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (var i = 0; i < scenarios.Count; i++)
|
||||||
|
{
|
||||||
|
if (i > 0)
|
||||||
|
await Task.Delay(SessionStagger, ct);
|
||||||
|
|
||||||
|
var state = await RegisterAndStartAsync(
|
||||||
|
scenarios[i], resolvedSpeed, userId, preset.Id, ct);
|
||||||
|
started.Add(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
foreach (var run in started)
|
||||||
|
Cancel(run.RunId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SimulationSessionState
|
||||||
|
{
|
||||||
|
SessionId = preset.Id,
|
||||||
|
Name = preset.Name,
|
||||||
StartedAt = startedAt,
|
StartedAt = startedAt,
|
||||||
TotalOffsetMinutes = totalOffset,
|
RunIds = started.Select(s => s.RunId).ToList(),
|
||||||
PatientDisplayName = $"{scenario.Patient.FirstName} {scenario.Patient.LastName}",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await PersistNewRunAsync(state, ct);
|
|
||||||
|
|
||||||
var cts = new CancellationTokenSource();
|
|
||||||
var ctx = new RunContext(state, cts, scenario);
|
|
||||||
if (!_runs.TryAdd(runId, ctx))
|
|
||||||
throw new ConflictException("Failed to register simulation run.", "SIMULATION_REGISTER_FAILED");
|
|
||||||
|
|
||||||
_ = Task.Run(() => ExecuteAsync(ctx, CancellationToken.None), CancellationToken.None);
|
|
||||||
|
|
||||||
return state.Snapshot();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Cancel(Guid runId)
|
public bool Cancel(Guid runId)
|
||||||
@@ -131,6 +191,44 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<SimulationRunState> RegisterAndStartAsync(
|
||||||
|
ScenarioFile scenario,
|
||||||
|
double speed,
|
||||||
|
string startedByUserId,
|
||||||
|
string? sessionId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var totalOffset = scenario.Events.Count == 0
|
||||||
|
? 0
|
||||||
|
: scenario.Events.Max(e => e.OffsetMinutes);
|
||||||
|
|
||||||
|
var runId = Guid.NewGuid();
|
||||||
|
var startedAt = DateTimeOffset.UtcNow;
|
||||||
|
var state = new SimulationRunState
|
||||||
|
{
|
||||||
|
RunId = runId,
|
||||||
|
ScenarioId = scenario.Scenario.Id,
|
||||||
|
ScenarioName = scenario.Scenario.Name,
|
||||||
|
Speed = speed,
|
||||||
|
SessionId = sessionId,
|
||||||
|
StartedByUserId = startedByUserId,
|
||||||
|
StartedAt = startedAt,
|
||||||
|
TotalOffsetMinutes = totalOffset,
|
||||||
|
PatientDisplayName = $"{scenario.Patient.FirstName} {scenario.Patient.LastName}",
|
||||||
|
};
|
||||||
|
|
||||||
|
await PersistNewRunAsync(state, ct);
|
||||||
|
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
var ctx = new RunContext(state, cts, scenario);
|
||||||
|
if (!_runs.TryAdd(runId, ctx))
|
||||||
|
throw new ConflictException("Failed to register simulation run.", "SIMULATION_REGISTER_FAILED");
|
||||||
|
|
||||||
|
_ = Task.Run(() => ExecuteAsync(ctx, CancellationToken.None), CancellationToken.None);
|
||||||
|
|
||||||
|
return state.Snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ExecuteAsync(RunContext ctx, CancellationToken _)
|
private async Task ExecuteAsync(RunContext ctx, CancellationToken _)
|
||||||
{
|
{
|
||||||
var runId = ctx.State.RunId;
|
var runId = ctx.State.RunId;
|
||||||
@@ -198,6 +296,7 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
|||||||
ScenarioName = state.ScenarioName,
|
ScenarioName = state.ScenarioName,
|
||||||
Speed = state.Speed,
|
Speed = state.Speed,
|
||||||
Status = SimulationRunStatus.Pending,
|
Status = SimulationRunStatus.Pending,
|
||||||
|
SessionId = state.SessionId,
|
||||||
StartedByUserId = state.StartedByUserId,
|
StartedByUserId = state.StartedByUserId,
|
||||||
StartedAt = state.StartedAt,
|
StartedAt = state.StartedAt,
|
||||||
TotalOffsetMinutes = state.TotalOffsetMinutes,
|
TotalOffsetMinutes = state.TotalOffsetMinutes,
|
||||||
@@ -221,6 +320,7 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
|||||||
row.Status = status;
|
row.Status = status;
|
||||||
row.PatientId = state.PatientId;
|
row.PatientId = state.PatientId;
|
||||||
row.EncounterId = state.EncounterId;
|
row.EncounterId = state.EncounterId;
|
||||||
|
row.SessionId = state.SessionId;
|
||||||
row.ObservationsSent = state.ObservationsSent;
|
row.ObservationsSent = state.ObservationsSent;
|
||||||
row.MedicationsSent = state.MedicationsSent;
|
row.MedicationsSent = state.MedicationsSent;
|
||||||
row.OrdersPlaced = state.OrdersPlaced;
|
row.OrdersPlaced = state.OrdersPlaced;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
public sealed class SimulationSessionState
|
||||||
|
{
|
||||||
|
public string SessionId { get; init; } = null!;
|
||||||
|
public string Name { get; init; } = null!;
|
||||||
|
public DateTimeOffset StartedAt { get; init; }
|
||||||
|
public IReadOnlyList<Guid> RunIds { get; init; } = Array.Empty<Guid>();
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
This guide walks you through testing the VigilCare monitoring dashboard. You will review simulated patient scenarios, decide whether alerts are clinically meaningful, and record structured feedback that helps the team improve the system.
|
This guide walks you through testing the VigilCare monitoring dashboard. You will review simulated patient scenarios, decide whether alerts are clinically meaningful, and record structured feedback that helps the team improve the system.
|
||||||
|
|
||||||
No programming knowledge is required. A facilitator will start the backend services and open the dashboard in your browser.
|
No programming knowledge is required. Log in to the dashboard, open **Simulation**, and start a testing session — the ward populates itself.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ No programming knowledge is required. A facilitator will start the backend servi
|
|||||||
10. [Submitting your feedback](#10-submitting-your-feedback)
|
10. [Submitting your feedback](#10-submitting-your-feedback)
|
||||||
11. [Frequently asked questions](#11-frequently-asked-questions)
|
11. [Frequently asked questions](#11-frequently-asked-questions)
|
||||||
12. [New dashboard features](#12-new-dashboard-features)
|
12. [New dashboard features](#12-new-dashboard-features)
|
||||||
|
13. [Running your own simulation](#13-running-your-own-simulation)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -45,7 +46,9 @@ Your ratings are the primary output of this study. There are no right or wrong a
|
|||||||
|
|
||||||
### Simulated patients only
|
### Simulated patients only
|
||||||
|
|
||||||
All patients in this test are **fictional**. Data is generated by a replay simulator, not from real bedside monitors. Treat it like a training exercise, not live clinical work.
|
All patients in this test are **fictional**. Data is generated by the in-app simulation runner, not from real bedside monitors. Treat it like a training exercise, not live clinical work.
|
||||||
|
|
||||||
|
While simulation mode is on, a **SIMULATION MODE** banner appears at the top of the dashboard. Simulated patients also show a **SIM** badge on the ward. Never assume a SIM patient is a real admission.
|
||||||
|
|
||||||
### Not a production EHR
|
### Not a production EHR
|
||||||
|
|
||||||
@@ -55,28 +58,26 @@ This dashboard is a **research and evaluation tool**. It does not replace your h
|
|||||||
|
|
||||||
You can tap **Ack** and **Resolve** on alerts to walk through the workflow. In this environment those actions update the test database only.
|
You can tap **Ack** and **Resolve** on alerts to walk through the workflow. In this environment those actions update the test database only.
|
||||||
|
|
||||||
### Feedback stays in your browser (for now)
|
### Feedback is saved on the server
|
||||||
|
|
||||||
Your ratings are saved in the browser on the computer you use. Export them at the end of the session (see [§10](#10-submitting-your-feedback)) and send the file to the study facilitator. If you switch computers or clear browser data without exporting, ratings may be lost.
|
Your ratings are stored with the alert in the API (and carry which simulation scenario produced the alert when applicable). Export from **Alert Quality** or **Feedback Summary** at the end of the session if the study asks for a file copy.
|
||||||
|
|
||||||
### Default clinician ID
|
### Your login identity
|
||||||
|
|
||||||
Acknowledging alerts uses a demo identifier (`DR-DEMO`) unless the facilitator configures yours. This does not affect your feedback ratings.
|
Acknowledging alerts uses your signed-in name and role. Use the nurse or physician account provided for the session.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Getting to the dashboard
|
## 3. Getting to the dashboard
|
||||||
|
|
||||||
The facilitator will ensure the following are running. You only need the browser URL.
|
1. Open the dashboard URL provided for your site (often **http://localhost:5173** in a local lab).
|
||||||
|
2. Log in with the nurse or physician account you were given.
|
||||||
|
3. In the sidebar, open **Simulation**.
|
||||||
|
4. On the **Sessions** tab, pick a recommended session (see [§7](#7-recommended-testing-sessions)) and click **Start session**.
|
||||||
|
|
||||||
| What | Where |
|
Patients appear on **Virtual Ward** as the session runs. You do not need a terminal or any command-line tools.
|
||||||
|---|---|
|
|
||||||
| Dashboard | **http://localhost:5173** (or URL provided by facilitator) |
|
|
||||||
| Your role | Review alerts and submit feedback |
|
|
||||||
|
|
||||||
**On first open** you land on **Virtual Ward** — a list of active simulated patients sorted by acuity (NEWS2 score).
|
If the ward is empty and you have not started a session yet, go back to **Simulation → Sessions**. If a previous tester left simulated patients behind, use **Reset ward** on the Simulation page (see [§13](#13-running-your-own-simulation)).
|
||||||
|
|
||||||
If the ward list is empty, ask the facilitator to start or replay a scenario (see [§8](#8-scenario-scripts)).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -236,27 +237,27 @@ Short phrases are enough. Examples:
|
|||||||
|
|
||||||
**Goal:** Learn the UI and rate at least 5 alerts.
|
**Goal:** Learn the UI and rate at least 5 alerts.
|
||||||
|
|
||||||
1. Facilitator replays `stable-baseline-01` — confirm ward stays quiet or low acuity.
|
1. Open **Simulation → Sessions** and start **Session A — Quick orientation**.
|
||||||
2. Facilitator replays `uti-sepsis-elderly-01` at faster speed.
|
2. Watch Virtual Ward as the two patients appear (stable baseline, then UTI/sepsis).
|
||||||
3. You: Virtual Ward → open patient → review 2–3 alerts with reasoning + charts.
|
3. Virtual Ward → open a patient → review 2–3 alerts with reasoning + charts.
|
||||||
4. You: Alert Center → rate remaining alerts.
|
4. Alert Center → rate remaining alerts.
|
||||||
5. Feedback Summary → export CSV.
|
5. Feedback Summary or Alert Quality → export CSV.
|
||||||
|
|
||||||
### Session B — Alert quality deep dive (45–60 minutes)
|
### Session B — Alert quality deep dive (45–60 minutes)
|
||||||
|
|
||||||
**Goal:** Compare alert types across scenarios.
|
**Goal:** Compare alert types across contrasting scenarios.
|
||||||
|
|
||||||
1. `medication-false-alarm-01` — focus on false positives and missing context.
|
1. Open **Simulation → Sessions** and start **Session B — Alert quality deep dive**.
|
||||||
2. `uti-sepsis-elderly-01` — sepsis, NEWS2, bundle panel.
|
2. Rate alerts across the three patients; note differences between false-alarm and sepsis cases.
|
||||||
3. `respiratory-failure-asthma-01` or `post-op-hemorrhage-01` — deterioration patterns.
|
3. Open **Alert Quality** → filter by scenario (e.g. `medication-false-alarm-01` vs `uti-sepsis-elderly-01`).
|
||||||
4. Export CSV with notes on at least 10 alerts.
|
4. Export CSV — `scenarioId` / `sessionId` columns attribute each rating.
|
||||||
|
|
||||||
### Session C — Ward workflow (30 minutes)
|
### Session C — Ward workflow (30 minutes)
|
||||||
|
|
||||||
**Goal:** Test prioritization and handoff usability.
|
**Goal:** Test prioritization and handoff usability on a full ward.
|
||||||
|
|
||||||
1. Facilitator runs `replay-all` on the scenario folder (or 2–3 scenarios back-to-back).
|
1. Open **Simulation → Sessions** and start **Session C — Ward workflow** (seven concurrent patients at high speed).
|
||||||
2. You: Stay on Virtual Ward — note sort order as new patients appear.
|
2. Stay on Virtual Ward — note sort order as new patients appear.
|
||||||
3. Try sorting by different columns (qSOFA, Alerts, Sepsis) — which view is most useful?
|
3. Try sorting by different columns (qSOFA, Alerts, Sepsis) — which view is most useful?
|
||||||
4. Use the search bar and quick filters to narrow the list.
|
4. Use the search bar and quick filters to narrow the list.
|
||||||
5. Round on each high-NEWS2 patient — detail page only, no Alert Center until end.
|
5. Round on each high-NEWS2 patient — detail page only, no Alert Center until end.
|
||||||
@@ -267,13 +268,13 @@ Short phrases are enough. Examples:
|
|||||||
|
|
||||||
**Goal:** Evaluate the new dashboard features for clinical usefulness.
|
**Goal:** Evaluate the new dashboard features for clinical usefulness.
|
||||||
|
|
||||||
1. Facilitator replays 3–4 scenarios (including `uti-sepsis-elderly-01` for sepsis bundles).
|
1. Open **Simulation → Sessions** and start **Session D — New features**.
|
||||||
2. Open **Department Overview** — review the unit-level cards and summary stats.
|
2. Open **Department Overview** — review the unit-level cards and summary stats.
|
||||||
3. Open **Sepsis Bundle Board** — watch countdown timers and urgency sorting.
|
3. Open **Sepsis Bundle Board** — watch countdown timers and urgency sorting.
|
||||||
4. Return to **Virtual Ward** — test the search bar, quick filters, and column sorting.
|
4. Return to **Virtual Ward** — test the search bar, quick filters, and column sorting.
|
||||||
5. Open a patient → use the **Vitals Entry form** to record manual observations.
|
5. Open a patient → use the **Vitals Entry form** to record manual observations.
|
||||||
6. Wait for a critical alert → note the **critical alert banner**, sound, and browser notification.
|
6. Wait for a critical alert → note the **critical alert banner**, sound, and browser notification.
|
||||||
7. Acknowledge an alert using the new **role-aware modal** — review the note preview.
|
7. Acknowledge an alert using the **role-aware modal** — review the note preview.
|
||||||
8. Generate a **Handoff Report** and review the SBAR format.
|
8. Generate a **Handoff Report** and review the SBAR format.
|
||||||
9. Document: *Which new features would you use on a real ward? Which need improvement?*
|
9. Document: *Which new features would you use on a real ward? Which need improvement?*
|
||||||
|
|
||||||
@@ -281,7 +282,7 @@ Short phrases are enough. Examples:
|
|||||||
|
|
||||||
## 8. Scenario scripts
|
## 8. Scenario scripts
|
||||||
|
|
||||||
The facilitator runs these from a separate terminal. You watch the dashboard update.
|
Reference only — the clinical stories behind each scenario file. You start these from **Simulation → Sessions** (or **Scenarios** for a single file); you do not need to run commands.
|
||||||
|
|
||||||
| Scenario | Clinical story | What to evaluate |
|
| Scenario | Clinical story | What to evaluate |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -294,15 +295,7 @@ The facilitator runs these from a separate terminal. You watch the dashboard upd
|
|||||||
| `dka-electrolyte-01.json` | Metabolic emergency | Multi-parameter scoring, order visibility |
|
| `dka-electrolyte-01.json` | Metabolic emergency | Multi-parameter scoring, order visibility |
|
||||||
| `hypothermia-elderly-01.json` | Temperature-driven risk | Temp warnings, NEWS2 contribution |
|
| `hypothermia-elderly-01.json` | Temperature-driven risk | Temp warnings, NEWS2 contribution |
|
||||||
|
|
||||||
**Facilitator command (example):**
|
Replay **speed** is chosen on the Simulation page (Real time / Fast / Very fast / Instant). Session presets also carry a default speed you can override before starting.
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet run --project VigilCare.Simulator -- replay \
|
|
||||||
VigilCare.Simulator/Scenarios/List/uti-sepsis-elderly-01.json \
|
|
||||||
--speed 60
|
|
||||||
```
|
|
||||||
|
|
||||||
`--speed 60` means 60× real time — a 3-hour scenario completes in a few minutes. Ask the facilitator to pause between scenarios if you need more review time.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -377,30 +370,34 @@ Use this during or after your session.
|
|||||||
|
|
||||||
At the end of your session:
|
At the end of your session:
|
||||||
|
|
||||||
1. Open **Feedback Summary** (sidebar → Feedback).
|
1. Open **Alert Quality** (or **Feedback Summary**) from the sidebar.
|
||||||
2. Review totals and recent entries.
|
2. Review totals and, if simulation was used, the **By Scenario** breakdown.
|
||||||
3. Click **Export CSV**.
|
3. Click **Export CSV**.
|
||||||
4. Save the file (e.g. `vigilcare-feedback-dr-smith-2026-06-20.csv`).
|
4. Save the file (e.g. `vigilcare-feedback-dr-smith-2026-06-20.csv`).
|
||||||
5. Send to the facilitator via the method they specify (email, shared drive, study portal).
|
5. Send to the study contact via the method they specify (email, shared drive, study portal).
|
||||||
|
|
||||||
The CSV contains: alert ID, alert type, severity, your rating, notes, and timestamp. The research team aggregates exports from all participants.
|
The CSV includes alert ID, alert type, your rating, notes, timestamp, and — when simulation mode is enabled — **`scenarioId`** and **`sessionId`** so ratings can be compared across scenarios (especially useful for Session B).
|
||||||
|
|
||||||
**Optional:** Export JSON if the facilitator requests machine-readable format.
|
**Same computer:** Ratings are stored on the server for your account. **Different computer:** Export before switching devices if you need a local copy.
|
||||||
|
|
||||||
**Same computer:** Ratings persist if you refresh the page on the same browser. **Different computer:** Export before switching devices.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Frequently asked questions
|
## 11. Frequently asked questions
|
||||||
|
|
||||||
**I don’t see any patients.**
|
**I don’t see any patients.**
|
||||||
The simulator may not have run yet, or all encounters are discharged. Ask the facilitator to replay a scenario.
|
Open **Simulation → Sessions** and start a session (or an individual scenario on the Scenarios tab). If a previous tester left the ward empty after a reset, start Session A for a quick two-patient setup.
|
||||||
|
|
||||||
|
**How do I clear the ward for the next person?**
|
||||||
|
On **Simulation**, use **Reset ward**. Confirm by typing `RESET`. This deletes only simulated patients and their alerts/feedback — never real patients. Stop any active runs first (or use **Stop all runs**).
|
||||||
|
|
||||||
|
**What does the SIM badge mean?**
|
||||||
|
The patient was created by a simulation session or scenario replay. The amber **SIMULATION MODE** banner means the environment is allowed to create those patients. Treat SIM patients as training data only.
|
||||||
|
|
||||||
**Charts are empty but vitals show data.**
|
**Charts are empty but vitals show data.**
|
||||||
Wait a few seconds — the page polls every 5 seconds. If charts stay empty, tell the facilitator.
|
Wait a few seconds — the page polls every 5 seconds. If charts stay empty, refresh the patient page or tell the study contact.
|
||||||
|
|
||||||
**Can I rate the same alert twice?**
|
**Can I rate the same alert twice?**
|
||||||
You can change your rating; only the latest is kept per alert.
|
You can change your rating; only the latest is kept per alert per user.
|
||||||
|
|
||||||
**Do I have to acknowledge before rating?**
|
**Do I have to acknowledge before rating?**
|
||||||
No. Rate any alert in any status.
|
No. Rate any alert in any status.
|
||||||
@@ -409,7 +406,7 @@ No. Rate any alert in any status.
|
|||||||
*Useful* = good alert clinically. *Would act* = you would specifically change care because of it. An alert can be useful information but not change your plan — use the button that best matches your reasoning.
|
*Useful* = good alert clinically. *Would act* = you would specifically change care because of it. An alert can be useful information but not change your plan — use the button that best matches your reasoning.
|
||||||
|
|
||||||
**The replay bar doesn’t pause the simulator.**
|
**The replay bar doesn’t pause the simulator.**
|
||||||
Correct — replay controls scrub observation/chart data already loaded in the browser (pause, speed, and **Next Alert →** move a local timeline). The facilitator controls simulator speed separately.
|
Correct — replay controls scrub observation/chart data already loaded in the browser (pause, speed, and **Next Alert →** move a local timeline). Session and scenario speed are set on the **Simulation** page before you start.
|
||||||
|
|
||||||
**The alert sound is annoying — can I turn it off?**
|
**The alert sound is annoying — can I turn it off?**
|
||||||
Yes. Click the sound toggle icon in the header bar to mute critical alert tones. The visual banner and browser notifications still appear.
|
Yes. Click the sound toggle icon in the header bar to mute critical alert tones. The visual banner and browser notifications still appear.
|
||||||
@@ -427,7 +424,7 @@ A hospital-wide view of all active sepsis bundles with live countdown timers. It
|
|||||||
Toggle in the header if your eyes prefer it; all screens support dark mode.
|
Toggle in the header if your eyes prefer it; all screens support dark mode.
|
||||||
|
|
||||||
**Who do I contact with problems?**
|
**Who do I contact with problems?**
|
||||||
Speak to your session facilitator. Technical issues (blank screen, errors) may need them to restart the API or dashboard.
|
Speak to your session contact. Technical issues (blank screen, errors) may need the API or dashboard restarted by whoever hosts the environment.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -446,9 +443,12 @@ Speak to your session facilitator. Technical issues (blank screen, errors) may n
|
|||||||
| Understand why an alert fired | Patient Detail → click alert → Reasoning |
|
| Understand why an alert fired | Patient Detail → click alert → Reasoning |
|
||||||
| Acknowledge an alert | Alert Center or Patient Detail → Ack button → modal |
|
| Acknowledge an alert | Alert Center or Patient Detail → Ack button → modal |
|
||||||
| Rate an alert | Feedback buttons under alert card or reasoning |
|
| Rate an alert | Feedback buttons under alert card or reasoning |
|
||||||
| See my ratings aggregate | Feedback Summary |
|
| See ratings by scenario | Alert Quality → scenario filter / By Scenario |
|
||||||
|
| See my ratings aggregate | Feedback Summary or Alert Quality |
|
||||||
| Generate a shift handoff | Virtual Ward → Handoff Report button |
|
| Generate a shift handoff | Virtual Ward → Handoff Report button |
|
||||||
| Submit results | Feedback Summary → Export CSV |
|
| Start a testing session | Simulation → Sessions |
|
||||||
|
| Clear simulated patients | Simulation → Reset ward |
|
||||||
|
| Submit results | Alert Quality or Feedback Summary → Export CSV |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -512,4 +512,28 @@ This makes the acknowledgment workflow more realistic and tracks who acknowledge
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 13. Running your own simulation
|
||||||
|
|
||||||
|
You can drive the entire evaluation from the dashboard — no terminal required.
|
||||||
|
|
||||||
|
### Session presets
|
||||||
|
|
||||||
|
**Simulation → Sessions** lists the recommended packs (A–D) with goals and estimated duration. **Start session** launches every scenario in the preset, staggered so the ward fills smoothly. Capacity is checked up front: if there are not enough free run slots, the Start button stays disabled with an explanation.
|
||||||
|
|
||||||
|
### Individual scenarios
|
||||||
|
|
||||||
|
**Simulation → Scenarios** is the full catalogue. Search and tag filters help you find a single case (e.g. a MIMIC-generated stay). Use this when you want one patient at a time rather than a full session.
|
||||||
|
|
||||||
|
### Speed
|
||||||
|
|
||||||
|
Choose **Real time**, **Fast**, **Very fast**, or **Instant** before starting. Session starts use this speed (overriding the preset default). Faster speeds are better for ward-round sessions; slower speeds help when you want to watch alerts arrive one by one.
|
||||||
|
|
||||||
|
### Reset
|
||||||
|
|
||||||
|
**Reset ward** removes all simulated patients, their observations, alerts, and feedback. Type `RESET` to confirm. Non-simulated patients are never deleted. Reset is blocked while runs are still active — use **Stop all runs** first.
|
||||||
|
|
||||||
|
After a reset, Virtual Ward and Alert Center should be empty of simulated data. Start Session A (or another preset) for the next tester.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
*Thank you for participating. Your clinical feedback directly shapes whether VigilCare alerts help or harm real ward workflows.*
|
*Thank you for participating. Your clinical feedback directly shapes whether VigilCare alerts help or harm real ward workflows.*
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ Welcome! The VigilCare Simulator lets you replay realistic hospital patient scen
|
|||||||
|
|
||||||
Think of it as a flight simulator, but for clinical decision support.
|
Think of it as a flight simulator, but for clinical decision support.
|
||||||
|
|
||||||
|
> **Clinicians evaluating the dashboard:** you do not need this CLI. Use **Simulation** in the web app (session presets, individual scenarios, and ward reset). See [clinical-testing-guide.md](clinical-testing-guide.md). This guide is for developers, CI, and anyone running `validate`, `dry-run`, `replay-all`, `mimic-generate`, or the `scripts/run-phase*-verification.sh` suites.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
@@ -19,6 +21,7 @@ Think of it as a flight simulator, but for clinical decision support.
|
|||||||
9. [Troubleshooting](#9-troubleshooting)
|
9. [Troubleshooting](#9-troubleshooting)
|
||||||
10. [Ward Outage Scenario (Climate Resilience)](#10-ward-outage-scenario-climate-resilience)
|
10. [Ward Outage Scenario (Climate Resilience)](#10-ward-outage-scenario-climate-resilience)
|
||||||
11. [MIMIC-IV Real Patient Data](#11-mimic-iv-real-patient-data)
|
11. [MIMIC-IV Real Patient Data](#11-mimic-iv-real-patient-data)
|
||||||
|
12. [CLI vs in-app simulation](#12-cli-vs-in-app-simulation)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -618,3 +621,16 @@ dotnet run --project VigilCare.Simulator -- mimic-list docs/MIMIC-IV/ --stay-id
|
|||||||
```
|
```
|
||||||
|
|
||||||
The table shows care unit, length of stay, and whether the patient expired -- useful for finding clinically interesting trajectories to replay.
|
The table shows care unit, length of stay, and whether the patient expired -- useful for finding clinically interesting trajectories to replay.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. CLI vs in-app simulation
|
||||||
|
|
||||||
|
| Audience | Tool |
|
||||||
|
|---|---|
|
||||||
|
| Clinicians / clinical testers | Dashboard **Simulation** page — session presets, scenario catalogue, speed control, ward reset. See [clinical-testing-guide.md](clinical-testing-guide.md). |
|
||||||
|
| Developers / CI | This CLI — `validate`, `dry-run`, `replay`, `replay-all`, `mimic-list`, `mimic-generate`, and the `scripts/run-phase*-verification.sh` suites. |
|
||||||
|
|
||||||
|
The CLI is **not deprecated**. In-app simulation (Phases 36–38) reuses the same scenario JSON files under `VigilCare.Simulator/Scenarios/` and the same `ReplayEngine`. Prefer the dashboard when a clinician should run Sessions A–D without a terminal; prefer the CLI when scripting, validating scenario files, generating MIMIC scenarios, or driving verification scripts.
|
||||||
|
|
||||||
|
In-app simulation is gated by `Simulation:Enabled` (default **false**). Never enable it against a database that holds real patient data.
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import ResetWardPanel from '@/components/simulation/ResetWardPanel.vue'
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
simulatedPatients: 12,
|
||||||
|
encounters: 12,
|
||||||
|
observations: 340,
|
||||||
|
alerts: 28,
|
||||||
|
activeRuns: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ResetWardPanel', () => {
|
||||||
|
it('displaysCounts', () => {
|
||||||
|
const wrapper = mount(ResetWardPanel, { props: { summary } })
|
||||||
|
expect(wrapper.text()).toContain('12 simulated patients')
|
||||||
|
expect(wrapper.text()).toContain('340 observations')
|
||||||
|
expect(wrapper.text()).toContain('28 alerts')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requiresTypedConfirmation', async () => {
|
||||||
|
const wrapper = mount(ResetWardPanel, {
|
||||||
|
props: { summary },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.findAll('button').find(b => b.text() === 'Reset ward').trigger('click')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('Type')
|
||||||
|
expect(document.body.textContent).toContain('RESET')
|
||||||
|
|
||||||
|
const input = document.body.querySelector('input')
|
||||||
|
expect(input).toBeTruthy()
|
||||||
|
input.value = 'RESET'
|
||||||
|
input.dispatchEvent(new Event('input'))
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
// Confirm button is the danger button inside the modal (last Reset ward).
|
||||||
|
const buttons = [...document.body.querySelectorAll('button')]
|
||||||
|
.filter(b => b.textContent.trim() === 'Reset ward')
|
||||||
|
const confirmBtn = buttons.at(-1)
|
||||||
|
expect(confirmBtn.disabled).toBe(false)
|
||||||
|
|
||||||
|
confirmBtn.click()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.emitted('purge')).toHaveLength(1)
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocksWhileRunsActive', () => {
|
||||||
|
const wrapper = mount(ResetWardPanel, {
|
||||||
|
props: {
|
||||||
|
summary: { ...summary, activeRuns: 2 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Reset is blocked')
|
||||||
|
expect(wrapper.text()).toContain('Stop all runs')
|
||||||
|
|
||||||
|
const resetBtn = wrapper.findAll('button').find(b => b.text() === 'Reset ward')
|
||||||
|
expect(resetBtn.attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import SessionCard from '@/components/simulation/SessionCard.vue'
|
||||||
|
|
||||||
|
const baseSession = {
|
||||||
|
id: 'session-b-alert-quality',
|
||||||
|
name: 'Session B — Alert quality deep dive',
|
||||||
|
goal: 'Compare alert types across contrasting scenarios.',
|
||||||
|
estimatedMinutes: 50,
|
||||||
|
defaultSpeed: 120,
|
||||||
|
scenarios: [
|
||||||
|
{ id: 'medication-false-alarm-01', name: 'Medication False Alarm' },
|
||||||
|
{ id: 'uti-sepsis-elderly-01', name: 'UTI Sepsis Elderly' },
|
||||||
|
{ id: 'respiratory-failure-asthma-01', name: 'Respiratory Failure' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SessionCard', () => {
|
||||||
|
it('rendersPresetSummary', () => {
|
||||||
|
const wrapper = mount(SessionCard, {
|
||||||
|
props: { session: baseSession },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain(baseSession.name)
|
||||||
|
expect(wrapper.text()).toContain(baseSession.goal)
|
||||||
|
expect(wrapper.text()).toContain('~50 min')
|
||||||
|
expect(wrapper.text()).toContain('3 patients')
|
||||||
|
expect(wrapper.text()).toContain('Medication False Alarm')
|
||||||
|
expect(wrapper.text()).toContain('UTI Sepsis Elderly')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disablesStartWhenInsufficientCapacity', async () => {
|
||||||
|
const wrapper = mount(SessionCard, {
|
||||||
|
props: {
|
||||||
|
session: baseSession,
|
||||||
|
activeRunCount: 0,
|
||||||
|
maxConcurrentRuns: 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const startBtn = wrapper.findAll('button').find(b => b.text() === 'Start session')
|
||||||
|
expect(startBtn.attributes('disabled')).toBeDefined()
|
||||||
|
expect(startBtn.attributes('title')).toMatch(/Needs 3 run slots/)
|
||||||
|
|
||||||
|
await startBtn.trigger('click')
|
||||||
|
expect(wrapper.emitted('start')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emitsStartWhenCapacityAvailable', async () => {
|
||||||
|
const wrapper = mount(SessionCard, {
|
||||||
|
props: {
|
||||||
|
session: baseSession,
|
||||||
|
activeRunCount: 0,
|
||||||
|
maxConcurrentRuns: 8,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const startBtn = wrapper.findAll('button').find(b => b.text() === 'Start session')
|
||||||
|
await startBtn.trigger('click')
|
||||||
|
expect(wrapper.emitted('start')).toEqual([['session-b-alert-quality']])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,24 +5,36 @@ import SimulationControlView from '@/views/SimulationControlView.vue'
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
fetchScenarios,
|
fetchScenarios,
|
||||||
|
fetchSessions,
|
||||||
fetchRuns,
|
fetchRuns,
|
||||||
fetchSimulationConfig,
|
fetchSimulationConfig,
|
||||||
|
fetchDataSummary,
|
||||||
startRun,
|
startRun,
|
||||||
|
startSession,
|
||||||
stopRun,
|
stopRun,
|
||||||
|
purgeData,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
fetchScenarios: vi.fn(),
|
fetchScenarios: vi.fn(),
|
||||||
|
fetchSessions: vi.fn(),
|
||||||
fetchRuns: vi.fn(),
|
fetchRuns: vi.fn(),
|
||||||
fetchSimulationConfig: vi.fn(),
|
fetchSimulationConfig: vi.fn(),
|
||||||
|
fetchDataSummary: vi.fn(),
|
||||||
startRun: vi.fn(),
|
startRun: vi.fn(),
|
||||||
|
startSession: vi.fn(),
|
||||||
stopRun: vi.fn(),
|
stopRun: vi.fn(),
|
||||||
|
purgeData: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/api/simulation', () => ({
|
vi.mock('@/api/simulation', () => ({
|
||||||
fetchScenarios,
|
fetchScenarios,
|
||||||
|
fetchSessions,
|
||||||
fetchRuns,
|
fetchRuns,
|
||||||
fetchSimulationConfig,
|
fetchSimulationConfig,
|
||||||
|
fetchDataSummary,
|
||||||
startRun,
|
startRun,
|
||||||
|
startSession,
|
||||||
stopRun,
|
stopRun,
|
||||||
|
purgeData,
|
||||||
fetchRun: vi.fn(),
|
fetchRun: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -30,6 +42,10 @@ vi.mock('@/stores/ward', () => ({
|
|||||||
useWardStore: () => ({ loadEncounters: vi.fn() }),
|
useWardStore: () => ({ loadEncounters: vi.fn() }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/alerts', () => ({
|
||||||
|
useAlertStore: () => ({ loadGlobalAlerts: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
const scenarios = [
|
const scenarios = [
|
||||||
{
|
{
|
||||||
id: 'uti-sepsis-elderly-01',
|
id: 'uti-sepsis-elderly-01',
|
||||||
@@ -51,12 +67,34 @@ const scenarios = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const sessions = [
|
||||||
|
{
|
||||||
|
id: 'session-a-orientation',
|
||||||
|
name: 'Session A — Quick orientation',
|
||||||
|
goal: 'Learn the interface.',
|
||||||
|
estimatedMinutes: 25,
|
||||||
|
defaultSpeed: 60,
|
||||||
|
scenarios: [
|
||||||
|
{ id: 'stable-baseline-01', name: 'Stable Baseline' },
|
||||||
|
{ id: 'uti-sepsis-elderly-01', name: 'UTI Sepsis Elderly' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
describe('SimulationControlView', () => {
|
describe('SimulationControlView', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
fetchScenarios.mockResolvedValue(scenarios)
|
fetchScenarios.mockResolvedValue(scenarios)
|
||||||
|
fetchSessions.mockResolvedValue(sessions)
|
||||||
fetchRuns.mockResolvedValue([])
|
fetchRuns.mockResolvedValue([])
|
||||||
|
fetchDataSummary.mockResolvedValue({
|
||||||
|
simulatedPatients: 0,
|
||||||
|
encounters: 0,
|
||||||
|
observations: 0,
|
||||||
|
alerts: 0,
|
||||||
|
activeRuns: 0,
|
||||||
|
})
|
||||||
fetchSimulationConfig.mockResolvedValue({
|
fetchSimulationConfig.mockResolvedValue({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
maxSpeed: 600,
|
maxSpeed: 600,
|
||||||
@@ -64,8 +102,30 @@ describe('SimulationControlView', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('defaultsToSessionsTab', async () => {
|
||||||
|
const wrapper = mount(SimulationControlView)
|
||||||
|
await vi.waitFor(() => expect(wrapper.text()).toContain('Session A — Quick orientation'))
|
||||||
|
expect(wrapper.text()).toContain('Testing sessions')
|
||||||
|
expect(wrapper.text()).not.toContain('Scenario catalogue')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switchesToScenariosTab', async () => {
|
||||||
|
const wrapper = mount(SimulationControlView)
|
||||||
|
await vi.waitFor(() => expect(wrapper.text()).toContain('Session A'))
|
||||||
|
|
||||||
|
const scenariosTab = wrapper.findAll('button').find(b => b.text() === 'Scenarios')
|
||||||
|
await scenariosTab.trigger('click')
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(wrapper.text()).toContain('Scenario catalogue'))
|
||||||
|
expect(wrapper.text()).toContain('UTI Sepsis Elderly')
|
||||||
|
})
|
||||||
|
|
||||||
it('showsWallClockDurationForSelectedScenarioSpeed', async () => {
|
it('showsWallClockDurationForSelectedScenarioSpeed', async () => {
|
||||||
const wrapper = mount(SimulationControlView)
|
const wrapper = mount(SimulationControlView)
|
||||||
|
await vi.waitFor(() => expect(wrapper.text()).toContain('Session A'))
|
||||||
|
|
||||||
|
const scenariosTab = wrapper.findAll('button').find(b => b.text() === 'Scenarios')
|
||||||
|
await scenariosTab.trigger('click')
|
||||||
await vi.waitFor(() => expect(wrapper.text()).toContain('UTI Sepsis Elderly'))
|
await vi.waitFor(() => expect(wrapper.text()).toContain('UTI Sepsis Elderly'))
|
||||||
|
|
||||||
// Default Fast (60×): 480 sim minutes → 8 wall-clock minutes
|
// Default Fast (60×): 480 sim minutes → 8 wall-clock minutes
|
||||||
@@ -78,9 +138,12 @@ describe('SimulationControlView', () => {
|
|||||||
expect(instant.text()).toMatch(/1 minute for selected scenario/)
|
expect(instant.text()).toMatch(/1 minute for selected scenario/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
it('filtersCatalogueByTag', async () => {
|
it('filtersCatalogueByTag', async () => {
|
||||||
const wrapper = mount(SimulationControlView)
|
const wrapper = mount(SimulationControlView)
|
||||||
|
await vi.waitFor(() => expect(wrapper.text()).toContain('Session A'))
|
||||||
|
|
||||||
|
const scenariosTab = wrapper.findAll('button').find(b => b.text() === 'Scenarios')
|
||||||
|
await scenariosTab.trigger('click')
|
||||||
await vi.waitFor(() => expect(wrapper.text()).toContain('Stable Baseline'))
|
await vi.waitFor(() => expect(wrapper.text()).toContain('Stable Baseline'))
|
||||||
|
|
||||||
const sepsisChip = wrapper.findAll('button').find(b => b.text() === 'sepsis')
|
const sepsisChip = wrapper.findAll('button').find(b => b.text() === 'sepsis')
|
||||||
@@ -93,6 +156,11 @@ describe('SimulationControlView', () => {
|
|||||||
it('showsEmptyStateWhenCatalogueMissing', async () => {
|
it('showsEmptyStateWhenCatalogueMissing', async () => {
|
||||||
fetchScenarios.mockResolvedValue([])
|
fetchScenarios.mockResolvedValue([])
|
||||||
const wrapper = mount(SimulationControlView)
|
const wrapper = mount(SimulationControlView)
|
||||||
|
await vi.waitFor(() => expect(wrapper.text()).toContain('Session A'))
|
||||||
|
|
||||||
|
const scenariosTab = wrapper.findAll('button').find(b => b.text() === 'Scenarios')
|
||||||
|
await scenariosTab.trigger('click')
|
||||||
|
|
||||||
await vi.waitFor(() =>
|
await vi.waitFor(() =>
|
||||||
expect(wrapper.text()).toContain('Simulation:ScenarioDirectory'),
|
expect(wrapper.text()).toContain('Simulation:ScenarioDirectory'),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|||||||
import { setActivePinia, createPinia } from 'pinia'
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
import { useAlertQualityStore } from '@/stores/alertQuality'
|
import { useAlertQualityStore } from '@/stores/alertQuality'
|
||||||
|
|
||||||
vi.mock('@/api/alertQuality', () => ({
|
const {
|
||||||
|
submitAlertFeedback,
|
||||||
|
fetchQualityMetricsSummary,
|
||||||
|
fetchQualityMetrics,
|
||||||
|
fetchQualityFeedback,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
submitAlertFeedback: vi.fn().mockResolvedValue({ id: 'fb-1', createdAt: '2026-06-23T00:00:00Z' }),
|
submitAlertFeedback: vi.fn().mockResolvedValue({ id: 'fb-1', createdAt: '2026-06-23T00:00:00Z' }),
|
||||||
fetchQualityMetricsSummary: vi.fn().mockResolvedValue({
|
fetchQualityMetricsSummary: vi.fn().mockResolvedValue({
|
||||||
totalAlerts: 10, totalFeedback: 4,
|
totalAlerts: 10, totalFeedback: 4,
|
||||||
@@ -13,12 +18,54 @@ vi.mock('@/api/alertQuality', () => ({
|
|||||||
fetchQualityMetrics: vi.fn().mockResolvedValue({
|
fetchQualityMetrics: vi.fn().mockResolvedValue({
|
||||||
items: [{ alertType: 'News2Warning', usefulRate: 0.75, falsePositiveRate: 0.1, acknowledgementRate: 0.8, totalAlerts: 5 }],
|
items: [{ alertType: 'News2Warning', usefulRate: 0.75, falsePositiveRate: 0.1, acknowledgementRate: 0.8, totalAlerts: 5 }],
|
||||||
}),
|
}),
|
||||||
|
fetchQualityFeedback: vi.fn().mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
alertId: 'a1',
|
||||||
|
alertType: 'NEWS2_WARNING',
|
||||||
|
feedbackType: 'USEFUL',
|
||||||
|
comment: null,
|
||||||
|
createdAt: '2026-06-23T00:00:00Z',
|
||||||
|
scenarioId: 'uti-sepsis-elderly-01',
|
||||||
|
sessionId: 'session-b-alert-quality',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
alertId: 'a2',
|
||||||
|
alertType: 'NEWS2_WARNING',
|
||||||
|
feedbackType: 'FALSE_POSITIVE',
|
||||||
|
comment: null,
|
||||||
|
createdAt: '2026-06-23T01:00:00Z',
|
||||||
|
scenarioId: 'medication-false-alarm-01',
|
||||||
|
sessionId: 'session-b-alert-quality',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
alertId: 'a3',
|
||||||
|
alertType: 'NEWS2_WARNING',
|
||||||
|
feedbackType: 'FALSE_POSITIVE',
|
||||||
|
comment: null,
|
||||||
|
createdAt: '2026-06-23T02:00:00Z',
|
||||||
|
scenarioId: 'medication-false-alarm-01',
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/alertQuality', () => ({
|
||||||
|
submitAlertFeedback,
|
||||||
|
fetchQualityMetricsSummary,
|
||||||
|
fetchQualityMetrics,
|
||||||
|
fetchQualityFeedback,
|
||||||
FEEDBACK_TYPE_MAP: { useful: 'Useful' },
|
FEEDBACK_TYPE_MAP: { useful: 'Useful' },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe('alertQuality store', () => {
|
describe('alertQuality store', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads dashboard summary and snapshots', async () => {
|
it('loads dashboard summary and snapshots', async () => {
|
||||||
@@ -34,4 +81,39 @@ describe('alertQuality store', () => {
|
|||||||
await store.submitFeedback('alert-1', 'useful', 'test note')
|
await store.submitFeedback('alert-1', 'useful', 'test note')
|
||||||
expect(store.getFeedback('alert-1').rating).toBe('useful')
|
expect(store.getFeedback('alert-1').rating).toBe('useful')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('groupsFeedbackByScenario', async () => {
|
||||||
|
const store = useAlertQualityStore()
|
||||||
|
await store.loadDashboard()
|
||||||
|
|
||||||
|
expect(store.scenarioOptions).toEqual([
|
||||||
|
'medication-false-alarm-01',
|
||||||
|
'uti-sepsis-elderly-01',
|
||||||
|
])
|
||||||
|
|
||||||
|
const med = store.byScenario.find(s => s.scenarioId === 'medication-false-alarm-01')
|
||||||
|
expect(med.totalFeedback).toBe(2)
|
||||||
|
expect(med.falsePositiveRate).toBe(1)
|
||||||
|
expect(med.usefulRate).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passesScenarioFilterToFeedbackApi', async () => {
|
||||||
|
const store = useAlertQualityStore()
|
||||||
|
store.setScenarioFilter('uti-sepsis-elderly-01')
|
||||||
|
await store.loadDashboard()
|
||||||
|
|
||||||
|
expect(fetchQualityFeedback).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ scenarioId: 'uti-sepsis-elderly-01' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('csvIncludesScenarioColumns', async () => {
|
||||||
|
const store = useAlertQualityStore()
|
||||||
|
await store.loadDashboard()
|
||||||
|
const csv = store.buildCsv()
|
||||||
|
expect(csv.split('\n')[0]).toContain('scenarioId')
|
||||||
|
expect(csv.split('\n')[0]).toContain('sessionId')
|
||||||
|
expect(csv).toContain('uti-sepsis-elderly-01')
|
||||||
|
expect(csv).toContain('session-b-alert-quality')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -6,25 +6,43 @@ import { useSimulationStore } from '@/stores/simulation'
|
|||||||
const {
|
const {
|
||||||
fetchSimulationConfig,
|
fetchSimulationConfig,
|
||||||
fetchScenarios,
|
fetchScenarios,
|
||||||
|
fetchSessions,
|
||||||
fetchRuns,
|
fetchRuns,
|
||||||
startRun,
|
startRun,
|
||||||
|
startSession,
|
||||||
stopRun,
|
stopRun,
|
||||||
|
fetchDataSummary,
|
||||||
|
purgeData,
|
||||||
loadEncounters,
|
loadEncounters,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
fetchSimulationConfig: vi.fn(),
|
fetchSimulationConfig: vi.fn(),
|
||||||
fetchScenarios: vi.fn(),
|
fetchScenarios: vi.fn(),
|
||||||
|
fetchSessions: vi.fn(),
|
||||||
fetchRuns: vi.fn(),
|
fetchRuns: vi.fn(),
|
||||||
startRun: vi.fn(),
|
startRun: vi.fn(),
|
||||||
|
startSession: vi.fn(),
|
||||||
stopRun: vi.fn(),
|
stopRun: vi.fn(),
|
||||||
|
fetchDataSummary: vi.fn().mockResolvedValue({
|
||||||
|
simulatedPatients: 0,
|
||||||
|
encounters: 0,
|
||||||
|
observations: 0,
|
||||||
|
alerts: 0,
|
||||||
|
activeRuns: 0,
|
||||||
|
}),
|
||||||
|
purgeData: vi.fn(),
|
||||||
loadEncounters: vi.fn(),
|
loadEncounters: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/api/simulation', () => ({
|
vi.mock('@/api/simulation', () => ({
|
||||||
fetchSimulationConfig,
|
fetchSimulationConfig,
|
||||||
fetchScenarios,
|
fetchScenarios,
|
||||||
|
fetchSessions,
|
||||||
fetchRuns,
|
fetchRuns,
|
||||||
startRun,
|
startRun,
|
||||||
|
startSession,
|
||||||
stopRun,
|
stopRun,
|
||||||
|
fetchDataSummary,
|
||||||
|
purgeData,
|
||||||
fetchRun: vi.fn(),
|
fetchRun: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -32,6 +50,10 @@ vi.mock('@/stores/ward', () => ({
|
|||||||
useWardStore: () => ({ loadEncounters }),
|
useWardStore: () => ({ loadEncounters }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/alerts', () => ({
|
||||||
|
useAlertStore: () => ({ loadGlobalAlerts: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
function runningRun(overrides = {}) {
|
function runningRun(overrides = {}) {
|
||||||
return {
|
return {
|
||||||
runId: 'run-1',
|
runId: 'run-1',
|
||||||
|
|||||||
@@ -35,3 +35,12 @@ export function fetchQualityMetrics({ alertType, from, to } = {}) {
|
|||||||
const qs = params.toString()
|
const qs = params.toString()
|
||||||
return api.get(`/api/v1/alerts/quality-metrics${qs ? `?${qs}` : ''}`)
|
return api.get(`/api/v1/alerts/quality-metrics${qs ? `?${qs}` : ''}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchQualityFeedback({ scenarioId, from, to } = {}) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (scenarioId) params.set('scenarioId', scenarioId)
|
||||||
|
if (from) params.set('from', from)
|
||||||
|
if (to) params.set('to', to)
|
||||||
|
const qs = params.toString()
|
||||||
|
return api.get(`/api/v1/alerts/quality-metrics/feedback${qs ? `?${qs}` : ''}`)
|
||||||
|
}
|
||||||
@@ -2,7 +2,12 @@ import { api } from './client'
|
|||||||
|
|
||||||
export const fetchSimulationConfig = () => api.get('/api/v1/simulation/config')
|
export const fetchSimulationConfig = () => api.get('/api/v1/simulation/config')
|
||||||
export const fetchScenarios = () => api.get('/api/v1/simulation/scenarios')
|
export const fetchScenarios = () => api.get('/api/v1/simulation/scenarios')
|
||||||
|
export const fetchSessions = () => api.get('/api/v1/simulation/sessions')
|
||||||
export const fetchRuns = () => api.get('/api/v1/simulation/runs')
|
export const fetchRuns = () => api.get('/api/v1/simulation/runs')
|
||||||
export const fetchRun = (runId) => api.get(`/api/v1/simulation/runs/${runId}`)
|
export const fetchRun = (runId) => api.get(`/api/v1/simulation/runs/${runId}`)
|
||||||
export const startRun = (body) => api.post('/api/v1/simulation/runs', body)
|
export const startRun = (body) => api.post('/api/v1/simulation/runs', body)
|
||||||
|
export const startSession = (sessionId, body = {}) =>
|
||||||
|
api.post(`/api/v1/simulation/sessions/${sessionId}/start`, body)
|
||||||
export const stopRun = (runId) => api.post(`/api/v1/simulation/runs/${runId}/stop`)
|
export const stopRun = (runId) => api.post(`/api/v1/simulation/runs/${runId}/stop`)
|
||||||
|
export const fetchDataSummary = () => api.get('/api/v1/simulation/data/summary')
|
||||||
|
export const purgeData = () => api.delete('/api/v1/simulation/data')
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import Card from '@/components/ui/Card.vue'
|
||||||
|
import Button from '@/components/ui/Button.vue'
|
||||||
|
import Modal from '@/components/ui/Modal.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
summary: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
simulatedPatients: 0,
|
||||||
|
encounters: 0,
|
||||||
|
observations: 0,
|
||||||
|
alerts: 0,
|
||||||
|
activeRuns: 0,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
purging: { type: Boolean, default: false },
|
||||||
|
stoppingAll: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['purge', 'stop-all'])
|
||||||
|
|
||||||
|
const confirmOpen = ref(false)
|
||||||
|
const confirmText = ref('')
|
||||||
|
|
||||||
|
const hasActiveRuns = computed(() => (props.summary?.activeRuns ?? 0) > 0)
|
||||||
|
const patientCount = computed(() => props.summary?.simulatedPatients ?? 0)
|
||||||
|
const observationCount = computed(() => props.summary?.observations ?? 0)
|
||||||
|
const alertCount = computed(() => props.summary?.alerts ?? 0)
|
||||||
|
|
||||||
|
const countsSentence = computed(() => {
|
||||||
|
const patients = patientCount.value
|
||||||
|
const observations = observationCount.value
|
||||||
|
const alerts = alertCount.value
|
||||||
|
return [
|
||||||
|
`${patients} simulated patient${patients === 1 ? '' : 's'}`,
|
||||||
|
`${observations} observation${observations === 1 ? '' : 's'}`,
|
||||||
|
`${alerts} alert${alerts === 1 ? '' : 's'}`,
|
||||||
|
].join(', ')
|
||||||
|
})
|
||||||
|
|
||||||
|
const confirmEnabled = computed(() =>
|
||||||
|
confirmText.value.trim().toUpperCase() === 'RESET' && !props.purging,
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(confirmOpen, (open) => {
|
||||||
|
if (open) confirmText.value = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function openConfirm() {
|
||||||
|
if (hasActiveRuns.value) return
|
||||||
|
confirmOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirm() {
|
||||||
|
confirmOpen.value = false
|
||||||
|
confirmText.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConfirm() {
|
||||||
|
if (!confirmEnabled.value) return
|
||||||
|
emit('purge')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStopAll() {
|
||||||
|
emit('stop-all')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ closeConfirm })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card padding="md">
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Reset ward
|
||||||
|
</h2>
|
||||||
|
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<template v-if="patientCount > 0">
|
||||||
|
{{ patientCount }} simulated patient{{ patientCount === 1 ? '' : 's' }}
|
||||||
|
currently on the ward
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">
|
||||||
|
({{ observationCount }} observations, {{ alertCount }} alerts).
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
No simulated patients on the ward.
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="hasActiveRuns"
|
||||||
|
class="mt-2 text-sm text-amber-800 dark:text-amber-200"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
Reset is blocked while {{ summary.activeRuns }} run{{ summary.activeRuns === 1 ? '' : 's' }}
|
||||||
|
{{ summary.activeRuns === 1 ? 'is' : 'are' }} still active. Stop them first.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="hasActiveRuns"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
:disabled="stoppingAll"
|
||||||
|
@click="onStopAll"
|
||||||
|
>
|
||||||
|
{{ stoppingAll ? 'Stopping…' : 'Stop all runs' }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
:disabled="hasActiveRuns || purging || patientCount === 0"
|
||||||
|
:title="hasActiveRuns
|
||||||
|
? 'Stop all active runs before resetting the ward.'
|
||||||
|
: patientCount === 0
|
||||||
|
? 'Nothing to reset.'
|
||||||
|
: undefined"
|
||||||
|
@click="openConfirm"
|
||||||
|
>
|
||||||
|
Reset ward
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
:open="confirmOpen"
|
||||||
|
title="Reset simulated ward"
|
||||||
|
title-id="reset-ward-title"
|
||||||
|
@close="closeConfirm"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
This will permanently delete
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">{{ countsSentence }}</span>.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Alert feedback for those patients is deleted with them. Non-simulated patients are not affected.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Type <kbd class="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-xs dark:bg-gray-700">RESET</kbd>
|
||||||
|
to confirm
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
v-model="confirmText"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
|
||||||
|
placeholder="RESET"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button variant="secondary" size="sm" :disabled="purging" @click="closeConfirm">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!confirmEnabled"
|
||||||
|
@click="onConfirm"
|
||||||
|
>
|
||||||
|
{{ purging ? 'Resetting…' : 'Reset ward' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import Card from '@/components/ui/Card.vue'
|
||||||
|
import Button from '@/components/ui/Button.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
session: { type: Object, required: true },
|
||||||
|
activeRunCount: { type: Number, default: 0 },
|
||||||
|
maxConcurrentRuns: { type: Number, default: 0 },
|
||||||
|
starting: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['start'])
|
||||||
|
|
||||||
|
const scenarioCount = computed(() => props.session.scenarios?.length ?? 0)
|
||||||
|
|
||||||
|
const summaryLabel = computed(() => {
|
||||||
|
const minutes = props.session.estimatedMinutes
|
||||||
|
const patients = scenarioCount.value
|
||||||
|
const timePart = minutes != null ? `~${minutes} min` : null
|
||||||
|
const patientPart = `${patients} patient${patients === 1 ? '' : 's'}`
|
||||||
|
return [timePart, patientPart].filter(Boolean).join(' · ')
|
||||||
|
})
|
||||||
|
|
||||||
|
const scenarioNames = computed(() =>
|
||||||
|
(props.session.scenarios ?? []).map(s => s.name || s.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
const insufficientCapacity = computed(() => {
|
||||||
|
const max = props.maxConcurrentRuns
|
||||||
|
if (!max) return false
|
||||||
|
return props.activeRunCount + scenarioCount.value > max
|
||||||
|
})
|
||||||
|
|
||||||
|
const startDisabled = computed(() =>
|
||||||
|
props.starting || insufficientCapacity.value || scenarioCount.value === 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const disabledReason = computed(() => {
|
||||||
|
if (insufficientCapacity.value) {
|
||||||
|
const need = scenarioCount.value
|
||||||
|
const free = Math.max(0, props.maxConcurrentRuns - props.activeRunCount)
|
||||||
|
return `Needs ${need} run slot${need === 1 ? '' : 's'} but only ${free} available (max ${props.maxConcurrentRuns}). Stop active runs or reset the ward first.`
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
function onStart() {
|
||||||
|
if (startDisabled.value) return
|
||||||
|
emit('start', props.session.id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card padding="md" class="flex h-full flex-col transition hover:border-gray-300 dark:hover:border-gray-600">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ session.name }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="session.goal"
|
||||||
|
class="mt-2 text-sm text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ session.goal }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="mt-3 text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{{ summaryLabel }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
v-if="scenarioNames.length"
|
||||||
|
class="mt-3 list-inside list-disc space-y-1 text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<li v-for="name in scenarioNames" :key="name">{{ name }}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="mt-4 border-t border-gray-100 pt-4 dark:border-gray-800">
|
||||||
|
<Button
|
||||||
|
class="w-full"
|
||||||
|
size="sm"
|
||||||
|
:disabled="startDisabled"
|
||||||
|
:title="disabledReason"
|
||||||
|
@click="onStart"
|
||||||
|
>
|
||||||
|
Start session
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
@@ -4,13 +4,24 @@ import {
|
|||||||
submitAlertFeedback,
|
submitAlertFeedback,
|
||||||
fetchQualityMetricsSummary,
|
fetchQualityMetricsSummary,
|
||||||
fetchQualityMetrics,
|
fetchQualityMetrics,
|
||||||
|
fetchQualityFeedback,
|
||||||
} from '@/api/alertQuality'
|
} from '@/api/alertQuality'
|
||||||
|
|
||||||
|
function csvEscape(value) {
|
||||||
|
if (value == null) return ''
|
||||||
|
const s = String(value)
|
||||||
|
if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
export const useAlertQualityStore = defineStore('alertQuality', () => {
|
export const useAlertQualityStore = defineStore('alertQuality', () => {
|
||||||
const summary = ref(null)
|
const summary = ref(null)
|
||||||
const snapshots = ref([])
|
const snapshots = ref([])
|
||||||
|
const feedbackRows = ref([])
|
||||||
|
const availableScenarioIds = ref([])
|
||||||
const periodDays = ref(7)
|
const periodDays = ref(7)
|
||||||
const selectedAlertType = ref(null)
|
const selectedAlertType = ref(null)
|
||||||
|
const selectedScenarioId = ref(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
|
|
||||||
@@ -52,6 +63,35 @@ export const useAlertQualityStore = defineStore('alertQuality', () => {
|
|||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const scenarioOptions = computed(() => availableScenarioIds.value)
|
||||||
|
|
||||||
|
const byScenario = computed(() => {
|
||||||
|
const map = {}
|
||||||
|
for (const row of feedbackRows.value) {
|
||||||
|
const key = row.scenarioId || '(non-simulated)'
|
||||||
|
if (!map[key]) {
|
||||||
|
map[key] = {
|
||||||
|
scenarioId: row.scenarioId || null,
|
||||||
|
totalFeedback: 0,
|
||||||
|
useful: 0,
|
||||||
|
falsePositive: 0,
|
||||||
|
wouldAct: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const bucket = map[key]
|
||||||
|
bucket.totalFeedback += 1
|
||||||
|
if (row.feedbackType === 'USEFUL') bucket.useful += 1
|
||||||
|
if (row.feedbackType === 'FALSE_POSITIVE') bucket.falsePositive += 1
|
||||||
|
if (row.feedbackType === 'WOULD_ACT') bucket.wouldAct += 1
|
||||||
|
}
|
||||||
|
return Object.values(map).map((b) => ({
|
||||||
|
...b,
|
||||||
|
usefulRate: b.totalFeedback ? b.useful / b.totalFeedback : 0,
|
||||||
|
falsePositiveRate: b.totalFeedback ? b.falsePositive / b.totalFeedback : 0,
|
||||||
|
wouldActRate: b.totalFeedback ? b.wouldAct / b.totalFeedback : 0,
|
||||||
|
})).sort((a, b) => (a.scenarioId || '').localeCompare(b.scenarioId || ''))
|
||||||
|
})
|
||||||
|
|
||||||
function periodRange() {
|
function periodRange() {
|
||||||
const to = new Date().toISOString()
|
const to = new Date().toISOString()
|
||||||
const from = new Date(Date.now() - periodDays.value * 86_400_000).toISOString()
|
const from = new Date(Date.now() - periodDays.value * 86_400_000).toISOString()
|
||||||
@@ -63,16 +103,27 @@ export const useAlertQualityStore = defineStore('alertQuality', () => {
|
|||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const { from, to } = periodRange()
|
const { from, to } = periodRange()
|
||||||
const [sum, list] = await Promise.all([
|
const [sum, list, feedback] = await Promise.all([
|
||||||
fetchQualityMetricsSummary(from, to),
|
fetchQualityMetricsSummary(from, to),
|
||||||
fetchQualityMetrics({
|
fetchQualityMetrics({
|
||||||
alertType: selectedAlertType.value,
|
alertType: selectedAlertType.value,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
}),
|
}),
|
||||||
|
fetchQualityFeedback({
|
||||||
|
scenarioId: selectedScenarioId.value,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
summary.value = sum
|
summary.value = sum
|
||||||
snapshots.value = list.items ?? []
|
snapshots.value = list.items ?? []
|
||||||
|
feedbackRows.value = feedback.items ?? []
|
||||||
|
if (!selectedScenarioId.value) {
|
||||||
|
availableScenarioIds.value = [...new Set(
|
||||||
|
feedbackRows.value.map(r => r.scenarioId).filter(Boolean),
|
||||||
|
)].sort()
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
} finally {
|
} finally {
|
||||||
@@ -103,19 +154,75 @@ export const useAlertQualityStore = defineStore('alertQuality', () => {
|
|||||||
selectedAlertType.value = type
|
selectedAlertType.value = type
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setScenarioFilter(scenarioId) {
|
||||||
|
selectedScenarioId.value = scenarioId || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCsv() {
|
||||||
|
const includeScenario = feedbackRows.value.some(
|
||||||
|
r => Object.prototype.hasOwnProperty.call(r, 'scenarioId'),
|
||||||
|
)
|
||||||
|
const headers = [
|
||||||
|
'feedbackId',
|
||||||
|
'alertId',
|
||||||
|
'alertType',
|
||||||
|
'feedbackType',
|
||||||
|
'comment',
|
||||||
|
'createdAt',
|
||||||
|
]
|
||||||
|
if (includeScenario) {
|
||||||
|
headers.push('scenarioId', 'sessionId')
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [headers.join(',')]
|
||||||
|
for (const row of feedbackRows.value) {
|
||||||
|
const cols = [
|
||||||
|
row.id,
|
||||||
|
row.alertId,
|
||||||
|
row.alertType,
|
||||||
|
row.feedbackType,
|
||||||
|
row.comment,
|
||||||
|
row.createdAt,
|
||||||
|
]
|
||||||
|
if (includeScenario) {
|
||||||
|
cols.push(row.scenarioId ?? '', row.sessionId ?? '')
|
||||||
|
}
|
||||||
|
lines.push(cols.map(csvEscape).join(','))
|
||||||
|
}
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCsv() {
|
||||||
|
const csv = buildCsv()
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `alert-quality-feedback-${periodDays.value}d.csv`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
summary,
|
summary,
|
||||||
snapshots,
|
snapshots,
|
||||||
|
feedbackRows,
|
||||||
periodDays,
|
periodDays,
|
||||||
selectedAlertType,
|
selectedAlertType,
|
||||||
|
selectedScenarioId,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
summaryStats,
|
summaryStats,
|
||||||
byAlertType,
|
byAlertType,
|
||||||
|
byScenario,
|
||||||
|
scenarioOptions,
|
||||||
loadDashboard,
|
loadDashboard,
|
||||||
submitFeedback,
|
submitFeedback,
|
||||||
getFeedback,
|
getFeedback,
|
||||||
setPeriodDays,
|
setPeriodDays,
|
||||||
setAlertTypeFilter,
|
setAlertTypeFilter,
|
||||||
|
setScenarioFilter,
|
||||||
|
buildCsv,
|
||||||
|
exportCsv,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -3,11 +3,16 @@ import { ref, computed, watch } from 'vue'
|
|||||||
import {
|
import {
|
||||||
fetchSimulationConfig,
|
fetchSimulationConfig,
|
||||||
fetchScenarios,
|
fetchScenarios,
|
||||||
|
fetchSessions,
|
||||||
fetchRuns,
|
fetchRuns,
|
||||||
startRun,
|
startRun,
|
||||||
|
startSession,
|
||||||
stopRun,
|
stopRun,
|
||||||
|
fetchDataSummary,
|
||||||
|
purgeData,
|
||||||
} from '@/api/simulation'
|
} from '@/api/simulation'
|
||||||
import { useWardStore } from '@/stores/ward'
|
import { useWardStore } from '@/stores/ward'
|
||||||
|
import { useAlertStore } from '@/stores/alerts'
|
||||||
|
|
||||||
const ACTIVE_STATUSES = new Set(['PENDING', 'RUNNING'])
|
const ACTIVE_STATUSES = new Set(['PENDING', 'RUNNING'])
|
||||||
const POLL_INTERVAL_MS = 2_000
|
const POLL_INTERVAL_MS = 2_000
|
||||||
@@ -21,10 +26,20 @@ export const useSimulationStore = defineStore('simulation', () => {
|
|||||||
const maxSpeed = ref(null)
|
const maxSpeed = ref(null)
|
||||||
const maxConcurrentRuns = ref(0)
|
const maxConcurrentRuns = ref(0)
|
||||||
const scenarios = ref([])
|
const scenarios = ref([])
|
||||||
|
const sessions = ref([])
|
||||||
const runs = ref([])
|
const runs = ref([])
|
||||||
|
const dataSummary = ref({
|
||||||
|
simulatedPatients: 0,
|
||||||
|
encounters: 0,
|
||||||
|
observations: 0,
|
||||||
|
alerts: 0,
|
||||||
|
activeRuns: 0,
|
||||||
|
})
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
|
const successMessage = ref(null)
|
||||||
const starting = ref(false)
|
const starting = ref(false)
|
||||||
|
const purging = ref(false)
|
||||||
|
|
||||||
let pollTimer = null
|
let pollTimer = null
|
||||||
let stopActiveWatch = null
|
let stopActiveWatch = null
|
||||||
@@ -62,6 +77,16 @@ export const useSimulationStore = defineStore('simulation', () => {
|
|||||||
error.value = null
|
error.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearSuccess() {
|
||||||
|
successMessage.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionFitsCapacity(session) {
|
||||||
|
const need = session?.scenarios?.length ?? 0
|
||||||
|
if (!need || !maxConcurrentRuns.value) return false
|
||||||
|
return activeRuns.value.length + need <= maxConcurrentRuns.value
|
||||||
|
}
|
||||||
|
|
||||||
async function loadConfig() {
|
async function loadConfig() {
|
||||||
try {
|
try {
|
||||||
const data = await fetchSimulationConfig()
|
const data = await fetchSimulationConfig()
|
||||||
@@ -90,6 +115,32 @@ export const useSimulationStore = defineStore('simulation', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadSessions() {
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const data = await fetchSessions()
|
||||||
|
sessions.value = Array.isArray(data) ? data : []
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
sessions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDataSummary() {
|
||||||
|
try {
|
||||||
|
const data = await fetchDataSummary()
|
||||||
|
dataSummary.value = {
|
||||||
|
simulatedPatients: data?.simulatedPatients ?? 0,
|
||||||
|
encounters: data?.encounters ?? 0,
|
||||||
|
observations: data?.observations ?? 0,
|
||||||
|
alerts: data?.alerts ?? 0,
|
||||||
|
activeRuns: data?.activeRuns ?? activeRuns.value.length,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function detectNewlyRunning(previousById, nextRuns) {
|
function detectNewlyRunning(previousById, nextRuns) {
|
||||||
return nextRuns.some((run) => {
|
return nextRuns.some((run) => {
|
||||||
if (run.status !== 'RUNNING') return false
|
if (run.status !== 'RUNNING') return false
|
||||||
@@ -112,6 +163,7 @@ export const useSimulationStore = defineStore('simulation', () => {
|
|||||||
|
|
||||||
if (detectNewlyRunning(previousById, next)) {
|
if (detectNewlyRunning(previousById, next)) {
|
||||||
useWardStore().loadEncounters()
|
useWardStore().loadEncounters()
|
||||||
|
loadDataSummary()
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
@@ -121,6 +173,7 @@ export const useSimulationStore = defineStore('simulation', () => {
|
|||||||
async function start(scenarioId, speed) {
|
async function start(scenarioId, speed) {
|
||||||
starting.value = true
|
starting.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
successMessage.value = null
|
||||||
|
|
||||||
const scenario = scenarios.value.find(s => s.id === scenarioId)
|
const scenario = scenarios.value.find(s => s.id === scenarioId)
|
||||||
const placeholderId = `optimistic-${Date.now()}`
|
const placeholderId = `optimistic-${Date.now()}`
|
||||||
@@ -132,6 +185,7 @@ export const useSimulationStore = defineStore('simulation', () => {
|
|||||||
speed,
|
speed,
|
||||||
patientId: null,
|
patientId: null,
|
||||||
encounterId: null,
|
encounterId: null,
|
||||||
|
sessionId: null,
|
||||||
patientDisplayName: '',
|
patientDisplayName: '',
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
elapsedRealSeconds: 0,
|
elapsedRealSeconds: 0,
|
||||||
@@ -155,6 +209,7 @@ export const useSimulationStore = defineStore('simulation', () => {
|
|||||||
if (run.status === 'RUNNING') {
|
if (run.status === 'RUNNING') {
|
||||||
useWardStore().loadEncounters()
|
useWardStore().loadEncounters()
|
||||||
}
|
}
|
||||||
|
await loadDataSummary()
|
||||||
return run
|
return run
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
runs.value = runs.value.filter(r => r.runId !== placeholderId)
|
runs.value = runs.value.filter(r => r.runId !== placeholderId)
|
||||||
@@ -165,6 +220,24 @@ export const useSimulationStore = defineStore('simulation', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startSessionRun(sessionId, speed) {
|
||||||
|
starting.value = true
|
||||||
|
error.value = null
|
||||||
|
successMessage.value = null
|
||||||
|
try {
|
||||||
|
const session = await startSession(sessionId, { speed })
|
||||||
|
await refreshRuns()
|
||||||
|
useWardStore().loadEncounters()
|
||||||
|
await loadDataSummary()
|
||||||
|
return session
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
starting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function stop(runId) {
|
async function stop(runId) {
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
@@ -175,6 +248,7 @@ export const useSimulationStore = defineStore('simulation', () => {
|
|||||||
} else {
|
} else {
|
||||||
runs.value = [run, ...runs.value]
|
runs.value = [run, ...runs.value]
|
||||||
}
|
}
|
||||||
|
await loadDataSummary()
|
||||||
return run
|
return run
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
@@ -182,6 +256,46 @@ export const useSimulationStore = defineStore('simulation', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function stopAll() {
|
||||||
|
error.value = null
|
||||||
|
const ids = activeRuns.value.map(r => r.runId)
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
await stop(id)
|
||||||
|
} catch {
|
||||||
|
// continue stopping others; last error stays on store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await refreshRuns()
|
||||||
|
await loadDataSummary()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function purge() {
|
||||||
|
purging.value = true
|
||||||
|
error.value = null
|
||||||
|
successMessage.value = null
|
||||||
|
try {
|
||||||
|
const result = await purgeData()
|
||||||
|
runs.value = []
|
||||||
|
await Promise.all([
|
||||||
|
loadDataSummary(),
|
||||||
|
useWardStore().loadEncounters(),
|
||||||
|
useAlertStore().loadGlobalAlerts(),
|
||||||
|
])
|
||||||
|
const patients = result?.patientsDeleted ?? 0
|
||||||
|
const alerts = result?.alertsDeleted ?? 0
|
||||||
|
successMessage.value =
|
||||||
|
`Ward reset — removed ${patients} simulated patient${patients === 1 ? '' : 's'}` +
|
||||||
|
` and ${alerts} alert${alerts === 1 ? '' : 's'}.`
|
||||||
|
return result
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
purging.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function syncPollTimer(active) {
|
function syncPollTimer(active) {
|
||||||
if (active) {
|
if (active) {
|
||||||
if (!pollTimer) {
|
if (!pollTimer) {
|
||||||
@@ -216,21 +330,32 @@ export const useSimulationStore = defineStore('simulation', () => {
|
|||||||
maxSpeed,
|
maxSpeed,
|
||||||
maxConcurrentRuns,
|
maxConcurrentRuns,
|
||||||
scenarios,
|
scenarios,
|
||||||
|
sessions,
|
||||||
runs,
|
runs,
|
||||||
|
dataSummary,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
successMessage,
|
||||||
starting,
|
starting,
|
||||||
|
purging,
|
||||||
activeRuns,
|
activeRuns,
|
||||||
recentRuns,
|
recentRuns,
|
||||||
hasActiveRun,
|
hasActiveRun,
|
||||||
atConcurrencyLimit,
|
atConcurrencyLimit,
|
||||||
scenariosByTag,
|
scenariosByTag,
|
||||||
clearError,
|
clearError,
|
||||||
|
clearSuccess,
|
||||||
|
sessionFitsCapacity,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
loadScenarios,
|
loadScenarios,
|
||||||
|
loadSessions,
|
||||||
|
loadDataSummary,
|
||||||
refreshRuns,
|
refreshRuns,
|
||||||
start,
|
start,
|
||||||
|
startSessionRun,
|
||||||
stop,
|
stop,
|
||||||
|
stopAll,
|
||||||
|
purge,
|
||||||
startPolling,
|
startPolling,
|
||||||
stopPolling,
|
stopPolling,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useAlertQualityStore } from '@/stores/alertQuality'
|
import { useAlertQualityStore } from '@/stores/alertQuality'
|
||||||
|
import { useSimulationStore } from '@/stores/simulation'
|
||||||
import { alertTypeLabel } from '@/api/normalize'
|
import { alertTypeLabel } from '@/api/normalize'
|
||||||
import Card from '@/components/ui/Card.vue'
|
import Card from '@/components/ui/Card.vue'
|
||||||
import Badge from '@/components/ui/Badge.vue'
|
import Badge from '@/components/ui/Badge.vue'
|
||||||
@@ -10,7 +11,20 @@ import Skeleton from '@/components/ui/Skeleton.vue'
|
|||||||
import AlertQualityChart from '@/components/charts/AlertQualityChart.vue'
|
import AlertQualityChart from '@/components/charts/AlertQualityChart.vue'
|
||||||
|
|
||||||
const store = useAlertQualityStore()
|
const store = useAlertQualityStore()
|
||||||
const { summaryStats, byAlertType, snapshots, loading, error, periodDays } = storeToRefs(store)
|
const simulationStore = useSimulationStore()
|
||||||
|
const {
|
||||||
|
summaryStats,
|
||||||
|
byAlertType,
|
||||||
|
byScenario,
|
||||||
|
scenarioOptions,
|
||||||
|
snapshots,
|
||||||
|
feedbackRows,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
periodDays,
|
||||||
|
selectedScenarioId,
|
||||||
|
} = storeToRefs(store)
|
||||||
|
const { enabled: simulationEnabled } = storeToRefs(simulationStore)
|
||||||
|
|
||||||
const periodOptions = [
|
const periodOptions = [
|
||||||
{ label: '7 days', value: 7 },
|
{ label: '7 days', value: 7 },
|
||||||
@@ -18,12 +32,32 @@ const periodOptions = [
|
|||||||
{ label: '30 days', value: 30 },
|
{ label: '30 days', value: 30 },
|
||||||
]
|
]
|
||||||
|
|
||||||
onMounted(() => store.loadDashboard())
|
const showScenarioAttribution = computed(() =>
|
||||||
|
simulationEnabled.value
|
||||||
|
|| feedbackRows.value.some(r => Object.prototype.hasOwnProperty.call(r, 'scenarioId')),
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!simulationStore.enabled) {
|
||||||
|
await simulationStore.loadConfig()
|
||||||
|
}
|
||||||
|
await store.loadDashboard()
|
||||||
|
})
|
||||||
|
|
||||||
function changePeriod(days) {
|
function changePeriod(days) {
|
||||||
store.setPeriodDays(days)
|
store.setPeriodDays(days)
|
||||||
store.loadDashboard()
|
store.loadDashboard()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changeScenario(event) {
|
||||||
|
const value = event.target.value || null
|
||||||
|
store.setScenarioFilter(value)
|
||||||
|
store.loadDashboard()
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(rate) {
|
||||||
|
return Math.round((rate ?? 0) * 100)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -48,9 +82,45 @@ function changePeriod(days) {
|
|||||||
<Button variant="secondary" size="sm" :disabled="loading" @click="store.loadDashboard()">
|
<Button variant="secondary" size="sm" :disabled="loading" @click="store.loadDashboard()">
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
:disabled="loading || feedbackRows.length === 0"
|
||||||
|
@click="store.exportCsv()"
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showScenarioAttribution"
|
||||||
|
class="flex flex-wrap items-end gap-4 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<label class="block min-w-[12rem] flex-1">
|
||||||
|
<span class="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Scenario
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
:value="selectedScenarioId ?? ''"
|
||||||
|
@change="changeScenario"
|
||||||
|
>
|
||||||
|
<option value="">All scenarios</option>
|
||||||
|
<option
|
||||||
|
v-for="id in scenarioOptions"
|
||||||
|
:key="id"
|
||||||
|
:value="id"
|
||||||
|
>
|
||||||
|
{{ id }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Filter feedback by the simulation scenario that produced the alert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||||
|
|
||||||
<!-- KPI cards -->
|
<!-- KPI cards -->
|
||||||
@@ -152,5 +222,34 @@ function changePeriod(days) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<!-- By scenario (simulation attribution) -->
|
||||||
|
<Card v-if="showScenarioAttribution">
|
||||||
|
<h2 class="mb-4 text-lg font-semibold dark:text-white">By Scenario</h2>
|
||||||
|
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<div
|
||||||
|
v-for="row in byScenario"
|
||||||
|
:key="row.scenarioId ?? 'non-simulated'"
|
||||||
|
class="flex flex-col gap-2 py-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium dark:text-white">
|
||||||
|
{{ row.scenarioId ?? 'Non-simulated' }}
|
||||||
|
</span>
|
||||||
|
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
({{ row.totalFeedback }} rating{{ row.totalFeedback === 1 ? '' : 's' }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Badge variant="success" size="xs">{{ pct(row.usefulRate) }}% useful</Badge>
|
||||||
|
<Badge variant="critical" size="xs">{{ pct(row.falsePositiveRate) }}% FP</Badge>
|
||||||
|
<Badge variant="info" size="xs">{{ pct(row.wouldActRate) }}% would-act</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="!byScenario.length" class="py-4 text-sm text-gray-400">
|
||||||
|
No feedback in this period. Rate alerts in Alert Center to populate scenario attribution.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -3,6 +3,8 @@ import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
|
|||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useSimulationStore } from '@/stores/simulation'
|
import { useSimulationStore } from '@/stores/simulation'
|
||||||
import ScenarioCard from '@/components/simulation/ScenarioCard.vue'
|
import ScenarioCard from '@/components/simulation/ScenarioCard.vue'
|
||||||
|
import SessionCard from '@/components/simulation/SessionCard.vue'
|
||||||
|
import ResetWardPanel from '@/components/simulation/ResetWardPanel.vue'
|
||||||
import SimulationRunPanel from '@/components/simulation/SimulationRunPanel.vue'
|
import SimulationRunPanel from '@/components/simulation/SimulationRunPanel.vue'
|
||||||
import Button from '@/components/ui/Button.vue'
|
import Button from '@/components/ui/Button.vue'
|
||||||
import Skeleton from '@/components/ui/Skeleton.vue'
|
import Skeleton from '@/components/ui/Skeleton.vue'
|
||||||
@@ -19,21 +21,28 @@ const SPEED_OPTIONS = [
|
|||||||
const simulationStore = useSimulationStore()
|
const simulationStore = useSimulationStore()
|
||||||
const {
|
const {
|
||||||
scenarios,
|
scenarios,
|
||||||
|
sessions,
|
||||||
activeRuns,
|
activeRuns,
|
||||||
recentRuns,
|
recentRuns,
|
||||||
|
dataSummary,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
successMessage,
|
||||||
starting,
|
starting,
|
||||||
|
purging,
|
||||||
atConcurrencyLimit,
|
atConcurrencyLimit,
|
||||||
maxConcurrentRuns,
|
maxConcurrentRuns,
|
||||||
maxSpeed,
|
maxSpeed,
|
||||||
} = storeToRefs(simulationStore)
|
} = storeToRefs(simulationStore)
|
||||||
|
|
||||||
|
const activeTab = ref('sessions')
|
||||||
const selectedScenarioId = ref(null)
|
const selectedScenarioId = ref(null)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const activeTag = ref(null)
|
const activeTag = ref(null)
|
||||||
const speed = ref(60)
|
const speed = ref(60)
|
||||||
const stoppingId = ref(null)
|
const stoppingId = ref(null)
|
||||||
|
const stoppingAll = ref(false)
|
||||||
|
const resetPanelRef = ref(null)
|
||||||
|
|
||||||
const availableSpeeds = computed(() => {
|
const availableSpeeds = computed(() => {
|
||||||
const cap = maxSpeed.value
|
const cap = maxSpeed.value
|
||||||
@@ -76,6 +85,11 @@ const filteredScenarios = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const summaryWithActive = computed(() => ({
|
||||||
|
...dataSummary.value,
|
||||||
|
activeRuns: activeRuns.value.length,
|
||||||
|
}))
|
||||||
|
|
||||||
function wallClockFor(multiplier) {
|
function wallClockFor(multiplier) {
|
||||||
if (!selectedScenario.value?.durationMinutes) return null
|
if (!selectedScenario.value?.durationMinutes) return null
|
||||||
return formatWallClockForSpeed(selectedScenario.value.durationMinutes, multiplier)
|
return formatWallClockForSpeed(selectedScenario.value.durationMinutes, multiplier)
|
||||||
@@ -100,6 +114,14 @@ async function onStart(scenarioId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onStartSession(sessionId) {
|
||||||
|
try {
|
||||||
|
await simulationStore.startSessionRun(sessionId, speed.value)
|
||||||
|
} catch (e) {
|
||||||
|
simulationStore.error = concurrencyErrorMessage(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onStop(runId) {
|
async function onStop(runId) {
|
||||||
stoppingId.value = runId
|
stoppingId.value = runId
|
||||||
try {
|
try {
|
||||||
@@ -111,6 +133,24 @@ async function onStop(runId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onStopAll() {
|
||||||
|
stoppingAll.value = true
|
||||||
|
try {
|
||||||
|
await simulationStore.stopAll()
|
||||||
|
} finally {
|
||||||
|
stoppingAll.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPurge() {
|
||||||
|
try {
|
||||||
|
await simulationStore.purge()
|
||||||
|
resetPanelRef.value?.closeConfirm()
|
||||||
|
} catch {
|
||||||
|
// error already on store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function selectScenario(id) {
|
function selectScenario(id) {
|
||||||
selectedScenarioId.value = id
|
selectedScenarioId.value = id
|
||||||
}
|
}
|
||||||
@@ -119,10 +159,16 @@ function toggleTag(tag) {
|
|||||||
activeTag.value = activeTag.value === tag ? null : tag
|
activeTag.value = activeTag.value === tag ? null : tag
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function switchTab(tab) {
|
||||||
|
activeTab.value = tab
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
simulationStore.loadSessions(),
|
||||||
simulationStore.loadScenarios(),
|
simulationStore.loadScenarios(),
|
||||||
simulationStore.refreshRuns(),
|
simulationStore.refreshRuns(),
|
||||||
|
simulationStore.loadDataSummary(),
|
||||||
])
|
])
|
||||||
simulationStore.startPolling()
|
simulationStore.startPolling()
|
||||||
if (!selectedScenarioId.value && scenarios.value[0]) {
|
if (!selectedScenarioId.value && scenarios.value[0]) {
|
||||||
@@ -146,7 +192,7 @@ onBeforeUnmount(() => {
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="text-xl font-bold dark:text-white">Simulation</h1>
|
<h1 class="text-xl font-bold dark:text-white">Simulation</h1>
|
||||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
Replay a recorded clinical scenario into this ward. All patients created here are simulated.
|
Start a testing session or replay an individual scenario. All patients created here are simulated.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -165,6 +211,30 @@ onBeforeUnmount(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="successMessage"
|
||||||
|
role="status"
|
||||||
|
class="flex items-start justify-between gap-4 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||||
|
>
|
||||||
|
<p>{{ successMessage }}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 font-medium underline"
|
||||||
|
@click="simulationStore.clearSuccess()"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResetWardPanel
|
||||||
|
ref="resetPanelRef"
|
||||||
|
:summary="summaryWithActive"
|
||||||
|
:purging="purging"
|
||||||
|
:stopping-all="stoppingAll"
|
||||||
|
@purge="onPurge"
|
||||||
|
@stop-all="onStopAll"
|
||||||
|
/>
|
||||||
|
|
||||||
<SimulationRunPanel
|
<SimulationRunPanel
|
||||||
v-if="activeRuns.length || recentRuns.length"
|
v-if="activeRuns.length || recentRuns.length"
|
||||||
:active-runs="activeRuns"
|
:active-runs="activeRuns"
|
||||||
@@ -201,7 +271,7 @@ onBeforeUnmount(() => {
|
|||||||
class="mt-0.5 block text-xs"
|
class="mt-0.5 block text-xs"
|
||||||
:class="speed === opt.multiplier ? 'text-blue-100' : 'text-gray-500 dark:text-gray-400'"
|
:class="speed === opt.multiplier ? 'text-blue-100' : 'text-gray-500 dark:text-gray-400'"
|
||||||
>
|
>
|
||||||
<template v-if="wallClockFor(opt.multiplier)">
|
<template v-if="activeTab === 'scenarios' && wallClockFor(opt.multiplier)">
|
||||||
{{ wallClockFor(opt.multiplier) }} for selected scenario
|
{{ wallClockFor(opt.multiplier) }} for selected scenario
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -210,14 +280,81 @@ onBeforeUnmount(() => {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="selectedScenario" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<p
|
||||||
|
v-if="activeTab === 'scenarios' && selectedScenario"
|
||||||
|
class="mt-2 text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
Durations above use
|
Durations above use
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ selectedScenario.name }}</span>
|
<span class="font-medium text-gray-700 dark:text-gray-300">{{ selectedScenario.name }}</span>
|
||||||
({{ selectedScenario.durationMinutes }} simulated minutes).
|
({{ selectedScenario.durationMinutes }} simulated minutes).
|
||||||
</p>
|
</p>
|
||||||
|
<p v-else class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Session starts use this speed (overriding each preset’s default).
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-labelledby="sim-catalogue-heading">
|
<div class="flex gap-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-b-2 px-4 py-2 text-sm font-medium transition"
|
||||||
|
:class="activeTab === 'sessions'
|
||||||
|
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
||||||
|
:aria-selected="activeTab === 'sessions'"
|
||||||
|
@click="switchTab('sessions')"
|
||||||
|
>
|
||||||
|
Sessions
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-b-2 px-4 py-2 text-sm font-medium transition"
|
||||||
|
:class="activeTab === 'scenarios'
|
||||||
|
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
||||||
|
:aria-selected="activeTab === 'scenarios'"
|
||||||
|
@click="switchTab('scenarios')"
|
||||||
|
>
|
||||||
|
Scenarios
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="activeTab === 'sessions'" aria-labelledby="sim-sessions-heading">
|
||||||
|
<h2
|
||||||
|
id="sim-sessions-heading"
|
||||||
|
class="mb-4 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
Testing sessions
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<Skeleton v-if="loading && sessions.length === 0 && scenarios.length === 0" :rows="3" />
|
||||||
|
|
||||||
|
<EmptyState
|
||||||
|
v-else-if="sessions.length === 0"
|
||||||
|
message="No session presets found. Check that sessions.json is present beside the scenario directory."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"
|
||||||
|
role="list"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="session in sessions"
|
||||||
|
:key="session.id"
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<SessionCard
|
||||||
|
:session="session"
|
||||||
|
:active-run-count="activeRuns.length"
|
||||||
|
:max-concurrent-runs="maxConcurrentRuns"
|
||||||
|
:starting="starting"
|
||||||
|
@start="onStartSession"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else aria-labelledby="sim-catalogue-heading">
|
||||||
<div class="mb-4 flex flex-wrap items-end justify-between gap-4">
|
<div class="mb-4 flex flex-wrap items-end justify-between gap-4">
|
||||||
<h2
|
<h2
|
||||||
id="sim-catalogue-heading"
|
id="sim-catalogue-heading"
|
||||||
|
|||||||
Reference in New Issue
Block a user