From 4399996448e1cab905fe47468530cf7aebd9b0f7 Mon Sep 17 00:00:00 2001 From: voltsrage Date: Tue, 23 Jun 2026 23:18:31 +0800 Subject: [PATCH] feature: Degraded Operations Visibility --- .../OperationsApiTests.cs | 72 ++++ .../GatewayStaleDetectorService.cs | 57 ++++ .../Configuration/GatewayMonitoringOptions.cs | 6 + .../Controllers/EncountersController.cs | 36 +- .../Controllers/OperationsController.cs | 65 ++++ .../Controllers/UsersController.cs | 48 +++ .../Records/Encounter/DischargeSummaryInfo.cs | 7 + .../Operations/GatewayDetailResponse.cs | 4 + .../Records/Operations/GatewayFleetFilter.cs | 1 + .../Records/Operations/GatewayFleetItem.cs | 11 + .../Operations/SiteGatewaySummaryResponse.cs | 7 + .../Records/User/ClinicalUserResponse.cs | 8 + .../Models/Records/User/CreateUserRequest.cs | 5 + .../Models/Records/User/UpdateUserRequest.cs | 4 + VigilCareClinicalAPI/Program.cs | 7 + .../Services/DischargeSummaryService.cs | 106 ++++++ .../Interfaces/IDischargeSummaryService.cs | 5 + .../Services/Interfaces/IOperationsService.cs | 6 + .../Services/Interfaces/IUserService.cs | 6 + .../Services/OperationsService.cs | 104 ++++++ VigilCareClinicalAPI/Services/UserService.cs | 121 +++++++ .../VigilCareClinicalAPI.csproj | 1 + VigilCareClinicalAPI/appsettings.json | 4 + docs/dashboard-gap-analysis.md | 103 +++--- ...ocker-compose-usage-and-troubleshooting.md | 10 + infra/grafana/dashboards/vigilcare.json | 108 ++++++ scripts/demo-network-partition.sh | 51 +++ scripts/run-phase23-verification.sh | 25 ++ vigilcare-dashboard/src/App.vue | 2 + .../__tests__/DischargeSummaryPanel.test.js | 43 +++ .../src/__tests__/GatewayOperations.spec.js | 41 +++ .../__tests__/ThresholdManagementView.test.js | 32 ++ .../src/__tests__/roleAccess.test.js | 31 ++ .../src/__tests__/thresholdForm.test.js | 49 +++ vigilcare-dashboard/src/api/audit.js | 54 +++ vigilcare-dashboard/src/api/client.js | 26 ++ vigilcare-dashboard/src/api/encounters.js | 27 +- vigilcare-dashboard/src/api/operations.js | 10 + vigilcare-dashboard/src/api/reconciliation.js | 26 ++ vigilcare-dashboard/src/api/thresholds.js | 17 + vigilcare-dashboard/src/api/users.js | 13 + .../src/components/DegradedModeBanner.vue | 15 + .../components/admin/ThresholdFormModal.vue | 92 +++++ .../src/components/admin/UserFormModal.vue | 148 ++++++++ .../src/components/alerts/AlertCard.vue | 9 +- .../src/components/charts/GcsHistory.vue | 64 ++-- .../src/components/charts/News2History.vue | 31 +- .../src/components/charts/QsofaHistory.vue | 92 ++--- .../src/components/charts/SofaHistory.vue | 74 ++-- .../src/components/charts/VitalChart.vue | 62 ++-- .../src/components/layout/AppShell.vue | 8 +- .../src/components/layout/AppSidebar.vue | 140 ++++++-- .../src/components/layout/MobileNav.vue | 39 ++- .../src/components/patient/AlertsList.vue | 91 ++--- .../patient/DischargeSummaryPanel.vue | 122 +++++++ .../src/components/replay/ReplayControls.vue | 16 +- .../src/components/ui/Button.vue | 6 +- .../src/components/ui/CollapsibleSection.vue | 50 +++ .../src/components/ui/Modal.vue | 41 ++- .../src/components/ui/SeverityBadge.vue | 58 ++++ .../src/components/ward/PatientCard.vue | 20 +- .../src/components/ward/PatientRow.vue | 26 +- .../src/components/ward/WardTable.vue | 54 ++- .../src/composables/auditFormat.js | 65 ++++ .../src/composables/reconciliationFormat.js | 41 +++ .../src/composables/roleAccess.js | 95 ++++++ .../src/composables/thresholdForm.js | 97 ++++++ .../src/composables/useApiMode.js | 9 + .../src/composables/useChartTheme.js | 138 ++++++++ .../src/composables/useFocusTrap.js | 67 ++++ .../src/composables/userForm.js | 70 ++++ vigilcare-dashboard/src/router/index.js | 55 ++- vigilcare-dashboard/src/stores/auth.js | 3 + .../src/stores/operationsStore.js | 42 +++ .../src/views/AuditLogView.vue | 321 ++++++++++++++++++ .../src/views/GatewayOperations.vue | 114 +++++++ vigilcare-dashboard/src/views/LoginView.vue | 8 +- .../src/views/PatientDetail.vue | 85 ++++- .../src/views/ReconciliationView.vue | 139 ++++++++ .../src/views/ThresholdManagementView.vue | 155 +++++++++ .../src/views/UserManagementView.vue | 151 ++++++++ 81 files changed, 3949 insertions(+), 323 deletions(-) create mode 100644 VigilCareClinicalAPI.Tests/OperationsApiTests.cs create mode 100644 VigilCareClinicalAPI/BackgroundServices/GatewayStaleDetectorService.cs create mode 100644 VigilCareClinicalAPI/Configuration/GatewayMonitoringOptions.cs create mode 100644 VigilCareClinicalAPI/Controllers/OperationsController.cs create mode 100644 VigilCareClinicalAPI/Controllers/UsersController.cs create mode 100644 VigilCareClinicalAPI/Models/Records/Encounter/DischargeSummaryInfo.cs create mode 100644 VigilCareClinicalAPI/Models/Records/Operations/GatewayDetailResponse.cs create mode 100644 VigilCareClinicalAPI/Models/Records/Operations/GatewayFleetFilter.cs create mode 100644 VigilCareClinicalAPI/Models/Records/Operations/GatewayFleetItem.cs create mode 100644 VigilCareClinicalAPI/Models/Records/Operations/SiteGatewaySummaryResponse.cs create mode 100644 VigilCareClinicalAPI/Models/Records/User/ClinicalUserResponse.cs create mode 100644 VigilCareClinicalAPI/Models/Records/User/CreateUserRequest.cs create mode 100644 VigilCareClinicalAPI/Models/Records/User/UpdateUserRequest.cs create mode 100644 VigilCareClinicalAPI/Services/DischargeSummaryService.cs create mode 100644 VigilCareClinicalAPI/Services/Interfaces/IDischargeSummaryService.cs create mode 100644 VigilCareClinicalAPI/Services/Interfaces/IOperationsService.cs create mode 100644 VigilCareClinicalAPI/Services/Interfaces/IUserService.cs create mode 100644 VigilCareClinicalAPI/Services/OperationsService.cs create mode 100644 VigilCareClinicalAPI/Services/UserService.cs create mode 100644 docs/docs/docker-compose-usage-and-troubleshooting.md create mode 100755 scripts/demo-network-partition.sh create mode 100755 scripts/run-phase23-verification.sh create mode 100644 vigilcare-dashboard/src/__tests__/DischargeSummaryPanel.test.js create mode 100644 vigilcare-dashboard/src/__tests__/GatewayOperations.spec.js create mode 100644 vigilcare-dashboard/src/__tests__/ThresholdManagementView.test.js create mode 100644 vigilcare-dashboard/src/__tests__/roleAccess.test.js create mode 100644 vigilcare-dashboard/src/__tests__/thresholdForm.test.js create mode 100644 vigilcare-dashboard/src/api/audit.js create mode 100644 vigilcare-dashboard/src/api/operations.js create mode 100644 vigilcare-dashboard/src/api/reconciliation.js create mode 100644 vigilcare-dashboard/src/api/thresholds.js create mode 100644 vigilcare-dashboard/src/api/users.js create mode 100644 vigilcare-dashboard/src/components/DegradedModeBanner.vue create mode 100644 vigilcare-dashboard/src/components/admin/ThresholdFormModal.vue create mode 100644 vigilcare-dashboard/src/components/admin/UserFormModal.vue create mode 100644 vigilcare-dashboard/src/components/patient/DischargeSummaryPanel.vue create mode 100644 vigilcare-dashboard/src/components/ui/CollapsibleSection.vue create mode 100644 vigilcare-dashboard/src/components/ui/SeverityBadge.vue create mode 100644 vigilcare-dashboard/src/composables/auditFormat.js create mode 100644 vigilcare-dashboard/src/composables/reconciliationFormat.js create mode 100644 vigilcare-dashboard/src/composables/roleAccess.js create mode 100644 vigilcare-dashboard/src/composables/thresholdForm.js create mode 100644 vigilcare-dashboard/src/composables/useApiMode.js create mode 100644 vigilcare-dashboard/src/composables/useChartTheme.js create mode 100644 vigilcare-dashboard/src/composables/useFocusTrap.js create mode 100644 vigilcare-dashboard/src/composables/userForm.js create mode 100644 vigilcare-dashboard/src/stores/operationsStore.js create mode 100644 vigilcare-dashboard/src/views/AuditLogView.vue create mode 100644 vigilcare-dashboard/src/views/GatewayOperations.vue create mode 100644 vigilcare-dashboard/src/views/ReconciliationView.vue create mode 100644 vigilcare-dashboard/src/views/ThresholdManagementView.vue create mode 100644 vigilcare-dashboard/src/views/UserManagementView.vue diff --git a/VigilCareClinicalAPI.Tests/OperationsApiTests.cs b/VigilCareClinicalAPI.Tests/OperationsApiTests.cs new file mode 100644 index 0000000..647ec65 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/OperationsApiTests.cs @@ -0,0 +1,72 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; + +[Collection("Integration")] +public class OperationsApiTests : IAsyncLifetime +{ + private readonly ApiFixture _fixture; + private readonly HttpClient _client; + + public OperationsApiTests(ApiFixture fixture) + { + _fixture = fixture; + _client = fixture.CreateClient(); + } + + public async Task InitializeAsync() + { + _client.AsAdmin(); + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var redis = scope.ServiceProvider.GetRequiredService(); + await DbResetHelper.ResetAsync(db); + await GatewayRegistrySeeder.SeedAsync(db); + + var gateway = await db.WardGateways.FindAsync(GatewayRegistrySeeder.DemoGatewayId); + gateway!.RecordHeartbeat(GatewayStatus.Degraded, 847, DateTimeOffset.UtcNow.AddMinutes(-5)); + await db.SaveChangesAsync(); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task GetFleet_ReturnsAllGateways() + { + var resp = await _client.GetAsync("/api/v1/operations/gateways"); + resp.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await resp.Content.ReadFromJsonAsync>>(); + body!.Data.Should().NotBeEmpty(); + } + + [Fact] + public async Task GetFleet_FilterDegraded() + { + var resp = await _client.GetAsync("/api/v1/operations/gateways?status=DEGRADED"); + var body = await resp.Content.ReadFromJsonAsync>>(); + body!.Data.Should().OnlyContain(g => g.Status == "DEGRADED"); + } + + [Fact] + public async Task GetSiteSummary_CountsByStatus() + { + var resp = await _client.GetAsync( + $"/api/v1/operations/sites/{GatewayRegistrySeeder.DemoSiteId}/summary"); + var body = await resp.Content.ReadFromJsonAsync>(); + body!.Data!.TotalGateways.Should().BeGreaterThan(0); + body.Data.Degraded.Should().BeGreaterThanOrEqualTo(1); + } + + [Fact] + public async Task GetGatewayDetail_IncludesSyncHistory() + { + var resp = await _client.GetAsync( + $"/api/v1/operations/gateways/{GatewayRegistrySeeder.DemoGatewayId}"); + resp.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await resp.Content.ReadFromJsonAsync>(); + body!.Data!.Gateway.GatewayCode.Should().Be("GW-ICU-3B"); + body.Data.RecentBatches.Should().NotBeNull(); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/BackgroundServices/GatewayStaleDetectorService.cs b/VigilCareClinicalAPI/BackgroundServices/GatewayStaleDetectorService.cs new file mode 100644 index 0000000..afacd54 --- /dev/null +++ b/VigilCareClinicalAPI/BackgroundServices/GatewayStaleDetectorService.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +public sealed class GatewayStaleDetectorService : BackgroundService +{ + private readonly IServiceScopeFactory _scopes; + private readonly GatewayMonitoringOptions _opts; + private readonly ClinicalMetrics _metrics; + private readonly ILogger _logger; + + public GatewayStaleDetectorService( + IServiceScopeFactory scopes, + IOptions opts, + ClinicalMetrics metrics, + ILogger logger) + { + _scopes = scopes; + _opts = opts.Value; + _metrics = metrics; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken ct) + { + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(_opts.PollIntervalMinutes)); + while (await timer.WaitForNextTickAsync(ct)) + await DetectStaleAsync(ct); + } + + private async Task DetectStaleAsync(CancellationToken ct) + { + await using var scope = _scopes.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var cutoff = DateTimeOffset.UtcNow.AddMinutes(-_opts.StaleThresholdMinutes); + + var stale = await db.WardGateways + .Include(g => g.Site) + .Where(g => g.Status != GatewayStatus.Offline + && (g.LastHeartbeatAt == null || g.LastHeartbeatAt < cutoff)) + .ToListAsync(ct); + + foreach (var gateway in stale) + { + gateway.MarkOffline(); + _logger.LogWarning( + "Gateway {Code} ({Department}) marked OFFLINE — last heartbeat {LastHeartbeat}", + gateway.GatewayCode, gateway.Department, gateway.LastHeartbeatAt); + + _metrics.WardGatewaysOffline + .WithLabels(gateway.Site.SiteCode) + .Inc(); + } + + if (stale.Count > 0) + await db.SaveChangesAsync(ct); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Configuration/GatewayMonitoringOptions.cs b/VigilCareClinicalAPI/Configuration/GatewayMonitoringOptions.cs new file mode 100644 index 0000000..a71bb75 --- /dev/null +++ b/VigilCareClinicalAPI/Configuration/GatewayMonitoringOptions.cs @@ -0,0 +1,6 @@ +public sealed class GatewayMonitoringOptions +{ + public const string Section = "GatewayMonitoring"; + public int StaleThresholdMinutes { get; init; } = 10; + public int PollIntervalMinutes { get; init; } = 5; +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Controllers/EncountersController.cs b/VigilCareClinicalAPI/Controllers/EncountersController.cs index ebe3dda..dd24093 100644 --- a/VigilCareClinicalAPI/Controllers/EncountersController.cs +++ b/VigilCareClinicalAPI/Controllers/EncountersController.cs @@ -12,8 +12,15 @@ using Microsoft.AspNetCore.Mvc; public class EncountersController : ControllerBase { private readonly IEncounterService _encounters; + private readonly IDischargeSummaryService _dischargeSummary; - public EncountersController(IEncounterService encounters) => _encounters = encounters; + public EncountersController( + IEncounterService encounters, + IDischargeSummaryService dischargeSummary) + { + _encounters = encounters; + _dischargeSummary = dischargeSummary; + } /// /// Lists encounters for ward dashboards with denormalized clinical summary fields. @@ -115,4 +122,31 @@ public class EncountersController : ControllerBase var timeline = await _encounters.GetTimelineAsync(id); return Ok(ApiResponse.Ok(timeline)); } + + /// + /// Returns discharge summary availability for a discharged encounter. + /// + [HttpGet("{id:guid}/discharge-summary")] + [AuthorizePermission(ClinicalPermissions.EncountersRead)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task GetDischargeSummary(Guid id) + { + var info = await _dischargeSummary.GetInfoAsync(id); + return Ok(ApiResponse.Ok(info)); + } + + /// + /// Downloads the generated discharge summary document. + /// + [HttpGet("{id:guid}/discharge-summary/content")] + [AuthorizePermission(ClinicalPermissions.EncountersRead)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + public async Task DownloadDischargeSummary(Guid id) + { + var content = await _dischargeSummary.GetContentAsync(id); + return File(content.Stream, content.ContentType, content.FileName); + } } diff --git a/VigilCareClinicalAPI/Controllers/OperationsController.cs b/VigilCareClinicalAPI/Controllers/OperationsController.cs new file mode 100644 index 0000000..befe204 --- /dev/null +++ b/VigilCareClinicalAPI/Controllers/OperationsController.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +/// +/// Gateway fleet operations: fleet listing, gateway detail, and site summaries. +/// +[ApiController] +[Route("api/v1/operations")] +[Authorize] +[Produces("application/json")] +public class OperationsController : ControllerBase +{ + private readonly IOperationsService _operations; + + public OperationsController(IOperationsService operations) => _operations = operations; + + /// + /// Lists registered gateways with optional status and site filters. + /// + /// Optional gateway status filter (DB literal, e.g. DEGRADED). + /// Optional site id filter. + /// Gateway fleet items. + [HttpGet("gateways")] + [AuthorizePermission(ClinicalPermissions.UsersAdmin)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task ListGateways( + [FromQuery] string? status, + [FromQuery] Guid? siteId, + CancellationToken ct) + { + var items = await _operations.GetGatewayFleetAsync( + new GatewayFleetFilter(status, siteId), ct); + return Ok(ApiResponse>.Ok(items)); + } + + /// + /// Gets a single gateway by id with buffer depth, heartbeat metadata, and sync history. + /// + /// Gateway id. + /// Gateway detail. + [HttpGet("gateways/{gatewayId:guid}")] + [AuthorizePermission(ClinicalPermissions.UsersAdmin)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task GetGateway(Guid gatewayId, CancellationToken ct) + { + var detail = await _operations.GetGatewayDetailAsync(gatewayId, ct); + return Ok(ApiResponse.Ok(detail)); + } + + /// + /// Returns an aggregate gateway summary for a site. + /// + /// Site id. + /// Site gateway summary. + [HttpGet("sites/{siteId:guid}/summary")] + [AuthorizePermission(ClinicalPermissions.UsersAdmin)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task GetSiteSummary(Guid siteId, CancellationToken ct) + { + var summary = await _operations.GetSiteSummaryAsync(siteId, ct); + return Ok(ApiResponse.Ok(summary)); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Controllers/UsersController.cs b/VigilCareClinicalAPI/Controllers/UsersController.cs new file mode 100644 index 0000000..7379b0c --- /dev/null +++ b/VigilCareClinicalAPI/Controllers/UsersController.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +/// +/// Clinical user account management for hospital IT administrators. +/// +[ApiController] +[Route("api/v1/users")] +[Produces("application/json")] +[Authorize] +public class UsersController : ControllerBase +{ + private readonly IUserService _users; + + public UsersController(IUserService users) => _users = users; + + /// Lists all clinical user accounts. + [HttpGet] + [AuthorizePermission(ClinicalPermissions.UsersAdmin)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task List() + { + var users = await _users.ListAsync(); + return Ok(ApiResponse>.Ok(users)); + } + + /// Creates a new clinical user account. + [HttpPost] + [AuthorizePermission(ClinicalPermissions.UsersAdmin)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + public async Task Create([FromBody] CreateUserRequest req) + { + var user = await _users.CreateAsync(req); + return StatusCode(201, ApiResponse.Created(user)); + } + + /// Updates role, display name, or active status for a user. + [HttpPatch("{id:guid}")] + [AuthorizePermission(ClinicalPermissions.UsersAdmin)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task Update(Guid id, [FromBody] UpdateUserRequest req) + { + var user = await _users.UpdateAsync(id, req); + return Ok(ApiResponse.Ok(user)); + } +} diff --git a/VigilCareClinicalAPI/Models/Records/Encounter/DischargeSummaryInfo.cs b/VigilCareClinicalAPI/Models/Records/Encounter/DischargeSummaryInfo.cs new file mode 100644 index 0000000..d2aae43 --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Encounter/DischargeSummaryInfo.cs @@ -0,0 +1,7 @@ +public record DischargeSummaryInfo( + string Status, + DateTimeOffset? DischargedAt, + string? ContentType, + string? FileName); + +public record DischargeSummaryContent(Stream Stream, string ContentType, string FileName); diff --git a/VigilCareClinicalAPI/Models/Records/Operations/GatewayDetailResponse.cs b/VigilCareClinicalAPI/Models/Records/Operations/GatewayDetailResponse.cs new file mode 100644 index 0000000..d25bcd6 --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Operations/GatewayDetailResponse.cs @@ -0,0 +1,4 @@ +public record GatewayDetailResponse( + GatewayFleetItem Gateway, + IReadOnlyList RecentBatches, + int PendingConflictCount); diff --git a/VigilCareClinicalAPI/Models/Records/Operations/GatewayFleetFilter.cs b/VigilCareClinicalAPI/Models/Records/Operations/GatewayFleetFilter.cs new file mode 100644 index 0000000..bacf1dc --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Operations/GatewayFleetFilter.cs @@ -0,0 +1 @@ +public record GatewayFleetFilter(string? Status, Guid? SiteId); diff --git a/VigilCareClinicalAPI/Models/Records/Operations/GatewayFleetItem.cs b/VigilCareClinicalAPI/Models/Records/Operations/GatewayFleetItem.cs new file mode 100644 index 0000000..0d2e51a --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Operations/GatewayFleetItem.cs @@ -0,0 +1,11 @@ +public record GatewayFleetItem( + Guid Id, + string GatewayCode, + string Department, + string SiteCode, + string SiteName, + string Status, + int ReportedBufferDepth, + DateTimeOffset? LastHeartbeatAt, + DateTimeOffset? LastSyncAt, + double? MinutesSinceHeartbeat); diff --git a/VigilCareClinicalAPI/Models/Records/Operations/SiteGatewaySummaryResponse.cs b/VigilCareClinicalAPI/Models/Records/Operations/SiteGatewaySummaryResponse.cs new file mode 100644 index 0000000..1c5c566 --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Operations/SiteGatewaySummaryResponse.cs @@ -0,0 +1,7 @@ +public record SiteGatewaySummaryResponse( + Guid SiteId, + int TotalGateways, + int Online, + int Degraded, + int Offline, + int TotalBufferedEvents); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Models/Records/User/ClinicalUserResponse.cs b/VigilCareClinicalAPI/Models/Records/User/ClinicalUserResponse.cs new file mode 100644 index 0000000..a1b854f --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/User/ClinicalUserResponse.cs @@ -0,0 +1,8 @@ +public record ClinicalUserResponse( + Guid Id, + string Username, + string DisplayName, + string Role, + bool IsActive, + DateTimeOffset CreatedAt, + DateTimeOffset? LastLoginAt); diff --git a/VigilCareClinicalAPI/Models/Records/User/CreateUserRequest.cs b/VigilCareClinicalAPI/Models/Records/User/CreateUserRequest.cs new file mode 100644 index 0000000..3f8e03e --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/User/CreateUserRequest.cs @@ -0,0 +1,5 @@ +public record CreateUserRequest( + string Username, + string Password, + string DisplayName, + string Role); diff --git a/VigilCareClinicalAPI/Models/Records/User/UpdateUserRequest.cs b/VigilCareClinicalAPI/Models/Records/User/UpdateUserRequest.cs new file mode 100644 index 0000000..23c73e2 --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/User/UpdateUserRequest.cs @@ -0,0 +1,4 @@ +public record UpdateUserRequest( + string? DisplayName, + string? Role, + bool? IsActive); diff --git a/VigilCareClinicalAPI/Program.cs b/VigilCareClinicalAPI/Program.cs index aa3f8ec..69b3a60 100644 --- a/VigilCareClinicalAPI/Program.cs +++ b/VigilCareClinicalAPI/Program.cs @@ -147,6 +147,9 @@ try builder.Configuration.GetSection(PhiEncryptionOptions.Section)); builder.Services.Configure(builder.Configuration.GetSection(ClinicalSyncOptions.Section)); + + builder.Services.Configure( + builder.Configuration.GetSection(GatewayMonitoringOptions.Section)); builder.Services.AddCors(options => { @@ -160,6 +163,7 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -198,6 +202,7 @@ try builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -205,6 +210,8 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/VigilCareClinicalAPI/Services/DischargeSummaryService.cs b/VigilCareClinicalAPI/Services/DischargeSummaryService.cs new file mode 100644 index 0000000..51a4b30 --- /dev/null +++ b/VigilCareClinicalAPI/Services/DischargeSummaryService.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Minio; +using Minio.DataModel.Args; +using Minio.Exceptions; + +public sealed class DischargeSummaryService : IDischargeSummaryService +{ + private readonly AppDbContext _db; + private readonly MinioOptions _minioOpts; + + public DischargeSummaryService(AppDbContext db, IOptions minioOpts) + { + _db = db; + _minioOpts = minioOpts.Value; + } + + public async Task GetInfoAsync(Guid encounterId, CancellationToken ct = default) + { + var encounter = await _db.Encounters + .AsNoTracking() + .FirstOrDefaultAsync(e => e.Id == encounterId, ct); + + if (encounter is null) + throw new NotFoundException("Encounter not found."); + + if (encounter.Status != EncounterStatus.Discharged) + { + return new DischargeSummaryInfo( + "NotDischarged", + encounter.DischargedAt, + null, + null); + } + + var exists = await ObjectExistsAsync(encounterId, ct); + if (!exists) + { + return new DischargeSummaryInfo( + "Pending", + encounter.DischargedAt, + null, + null); + } + + return new DischargeSummaryInfo( + "Ready", + encounter.DischargedAt, + "text/plain", + "discharge-summary.pdf"); + } + + public async Task GetContentAsync(Guid encounterId, CancellationToken ct = default) + { + var info = await GetInfoAsync(encounterId, ct); + + if (info.Status == "NotDischarged") + { + throw new ConflictException( + "Discharge summary is only available for discharged encounters.", + "ENCOUNTER_NOT_DISCHARGED"); + } + + if (info.Status == "Pending") + { + throw new NotFoundException( + "Discharge summary is still being generated.", + "DISCHARGE_SUMMARY_PENDING"); + } + + var client = MinioClientFactory.Build(_minioOpts); + var bucket = _minioOpts.BucketName; + var objectKey = ObjectKey(encounterId); + var ms = new MemoryStream(); + + await client.GetObjectAsync(new GetObjectArgs() + .WithBucket(bucket) + .WithObject(objectKey) + .WithCallbackStream(stream => stream.CopyTo(ms)), ct); + + ms.Position = 0; + return new DischargeSummaryContent(ms, info.ContentType!, info.FileName!); + } + + private async Task ObjectExistsAsync(Guid encounterId, CancellationToken ct) + { + var client = MinioClientFactory.Build(_minioOpts); + var bucket = _minioOpts.BucketName; + var objectKey = ObjectKey(encounterId); + + try + { + await client.StatObjectAsync(new StatObjectArgs() + .WithBucket(bucket) + .WithObject(objectKey), ct); + return true; + } + catch (ObjectNotFoundException) + { + return false; + } + } + + private static string ObjectKey(Guid encounterId) => + $"discharge-summaries/{encounterId}/summary.pdf"; +} diff --git a/VigilCareClinicalAPI/Services/Interfaces/IDischargeSummaryService.cs b/VigilCareClinicalAPI/Services/Interfaces/IDischargeSummaryService.cs new file mode 100644 index 0000000..be5ffaa --- /dev/null +++ b/VigilCareClinicalAPI/Services/Interfaces/IDischargeSummaryService.cs @@ -0,0 +1,5 @@ +public interface IDischargeSummaryService +{ + Task GetInfoAsync(Guid encounterId, CancellationToken ct = default); + Task GetContentAsync(Guid encounterId, CancellationToken ct = default); +} diff --git a/VigilCareClinicalAPI/Services/Interfaces/IOperationsService.cs b/VigilCareClinicalAPI/Services/Interfaces/IOperationsService.cs new file mode 100644 index 0000000..281662b --- /dev/null +++ b/VigilCareClinicalAPI/Services/Interfaces/IOperationsService.cs @@ -0,0 +1,6 @@ +public interface IOperationsService +{ + Task> GetGatewayFleetAsync(GatewayFleetFilter filter, CancellationToken ct); + Task GetGatewayDetailAsync(Guid gatewayId, CancellationToken ct); + Task GetSiteSummaryAsync(Guid siteId, CancellationToken ct); +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/Interfaces/IUserService.cs b/VigilCareClinicalAPI/Services/Interfaces/IUserService.cs new file mode 100644 index 0000000..655d017 --- /dev/null +++ b/VigilCareClinicalAPI/Services/Interfaces/IUserService.cs @@ -0,0 +1,6 @@ +public interface IUserService +{ + Task> ListAsync(); + Task CreateAsync(CreateUserRequest req); + Task UpdateAsync(Guid id, UpdateUserRequest req); +} diff --git a/VigilCareClinicalAPI/Services/OperationsService.cs b/VigilCareClinicalAPI/Services/OperationsService.cs new file mode 100644 index 0000000..cf2302b --- /dev/null +++ b/VigilCareClinicalAPI/Services/OperationsService.cs @@ -0,0 +1,104 @@ +using Dapper; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +public class OperationsService : IOperationsService +{ + private readonly AppDbContext _db; + private readonly IClinicalSyncService _sync; + + public OperationsService(AppDbContext db, IClinicalSyncService sync) + { + _db = db; + _sync = sync; + } + + public async Task> GetGatewayFleetAsync( + GatewayFleetFilter filter, CancellationToken ct) + { + await using var conn = new NpgsqlConnection(_db.Database.GetConnectionString()); + await conn.OpenAsync(ct); + + const string sql = """ + SELECT + g.id, + g.gateway_code AS GatewayCode, + g.department AS Department, + s.site_code AS SiteCode, + s.name AS SiteName, + g.status AS Status, + g.reported_buffer_depth AS ReportedBufferDepth, + g.last_heartbeat_at AS LastHeartbeatAt, + g.last_sync_at AS LastSyncAt, + EXTRACT(EPOCH FROM (NOW() - g.last_heartbeat_at)) / 60 AS MinutesSinceHeartbeat + FROM ward_gateways g + JOIN clinical_sites s ON s.id = g.site_id + WHERE (@Status IS NULL OR g.status = @Status) + AND (@SiteId IS NULL OR g.site_id = @SiteId) + ORDER BY g.status DESC, MinutesSinceHeartbeat DESC NULLS LAST + """; + + var rows = await conn.QueryAsync(sql, new + { + Status = filter.Status, + SiteId = filter.SiteId + }); + return rows.ToList(); + } + + public async Task GetGatewayDetailAsync(Guid gatewayId, CancellationToken ct) + { + var fleet = await GetGatewayFleetAsync(new GatewayFleetFilter(null, null), ct); + var gateway = fleet.FirstOrDefault(g => g.Id == gatewayId) + ?? throw new NotFoundException("Gateway not found.", "GATEWAY_NOT_FOUND"); + + var siteId = await _db.WardGateways + .Where(g => g.Id == gatewayId) + .Select(g => g.SiteId) + .FirstAsync(ct); + + var history = await _sync.GetSyncHistoryAsync(siteId, gatewayId, 10, ct); + + var lastConflictBatch = await _db.ClinicalSyncBatches + .AsNoTracking() + .Where(b => b.GatewayId == gatewayId && b.Status == ClinicalSyncBatchStatus.Conflict) + .OrderByDescending(b => b.SubmittedAt) + .Include(b => b.Conflicts) + .FirstOrDefaultAsync(ct); + + var pendingConflicts = lastConflictBatch?.Conflicts.Count ?? 0; + + return new GatewayDetailResponse(gateway, history, pendingConflicts); + } + + public async Task GetSiteSummaryAsync(Guid siteId, CancellationToken ct) + { + var siteExists = await _db.ClinicalSites.AnyAsync(s => s.Id == siteId, ct); + if (!siteExists) + throw new NotFoundException("Site not found.", "SITE_NOT_FOUND"); + + await using var conn = new NpgsqlConnection(_db.Database.GetConnectionString()); + await conn.OpenAsync(ct); + + const string sql = """ + SELECT + COUNT(*) AS TotalGateways, + COUNT(*) FILTER (WHERE status = 'ONLINE') AS Online, + COUNT(*) FILTER (WHERE status = 'DEGRADED') AS Degraded, + COUNT(*) FILTER (WHERE status = 'OFFLINE') AS Offline, + COALESCE(SUM(reported_buffer_depth), 0) AS TotalBufferedEvents + FROM ward_gateways + WHERE site_id = @SiteId + """; + + var row = await conn.QuerySingleAsync(sql, new { SiteId = siteId }); + + return new SiteGatewaySummaryResponse( + siteId, + (int)row.totalgateways, + (int)row.online, + (int)row.degraded, + (int)row.offline, + (int)row.totalbufferedevents); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/UserService.cs b/VigilCareClinicalAPI/Services/UserService.cs new file mode 100644 index 0000000..d868c90 --- /dev/null +++ b/VigilCareClinicalAPI/Services/UserService.cs @@ -0,0 +1,121 @@ +using Microsoft.EntityFrameworkCore; + +public class UserService : IUserService +{ + private readonly AppDbContext _db; + private readonly ICurrentUserService _currentUser; + + public UserService(AppDbContext db, ICurrentUserService currentUser) + { + _db = db; + _currentUser = currentUser; + } + + public async Task> ListAsync() + { + var users = await _db.ClinicalUsers + .AsNoTracking() + .OrderBy(u => u.Username) + .ToListAsync(); + return users.Select(Map).ToList(); + } + + public async Task CreateAsync(CreateUserRequest req) + { + ValidateUsername(req.Username); + ValidatePassword(req.Password); + ValidateDisplayName(req.DisplayName); + var role = ParseRole(req.Role); + + var exists = await _db.ClinicalUsers.AnyAsync(u => u.Username == req.Username); + if (exists) + throw new ConflictException( + $"Username '{req.Username}' is already taken.", + "USERNAME_CONFLICT"); + + var user = new ClinicalUser + { + Id = Guid.NewGuid(), + Username = req.Username.Trim(), + PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password), + DisplayName = req.DisplayName.Trim(), + Role = role, + IsActive = true, + CreatedAt = DateTimeOffset.UtcNow, + }; + _db.ClinicalUsers.Add(user); + await _db.SaveChangesAsync(); + return Map(user); + } + + public async Task UpdateAsync(Guid id, UpdateUserRequest req) + { + var user = await _db.ClinicalUsers.FindAsync(id); + if (user is null) + throw new NotFoundException("User not found.", "USER_NOT_FOUND"); + + if (req.DisplayName is not null) + { + ValidateDisplayName(req.DisplayName); + user.DisplayName = req.DisplayName.Trim(); + } + + if (req.Role is not null) + user.Role = ParseRole(req.Role); + + if (req.IsActive is not null) + { + if (!req.IsActive.Value && _currentUser.UserId == id) + throw new ValidationException( + "You cannot deactivate your own account.", + "SELF_DEACTIVATION_DENIED"); + user.IsActive = req.IsActive.Value; + } + + await _db.SaveChangesAsync(); + return Map(user); + } + + private static ClinicalUserResponse Map(ClinicalUser u) => + new(u.Id, u.Username, u.DisplayName, u.Role.ToDbString(), u.IsActive, u.CreatedAt, u.LastLoginAt); + + private static void ValidateUsername(string username) + { + if (string.IsNullOrWhiteSpace(username)) + throw new ValidationException("Username is required.", "USERNAME_REQUIRED"); + if (username.Trim().Length > 100) + throw new ValidationException("Username must be 100 characters or fewer.", "USERNAME_TOO_LONG"); + } + + private static void ValidatePassword(string password) + { + if (string.IsNullOrWhiteSpace(password)) + throw new ValidationException("Password is required.", "PASSWORD_REQUIRED"); + if (password.Length < 8) + throw new ValidationException("Password must be at least 8 characters.", "PASSWORD_TOO_SHORT"); + } + + private static void ValidateDisplayName(string displayName) + { + if (string.IsNullOrWhiteSpace(displayName)) + throw new ValidationException("Display name is required.", "DISPLAY_NAME_REQUIRED"); + if (displayName.Trim().Length > 200) + throw new ValidationException("Display name must be 200 characters or fewer.", "DISPLAY_NAME_TOO_LONG"); + } + + private static ClinicalRole ParseRole(string role) + { + if (string.IsNullOrWhiteSpace(role)) + throw new ValidationException("Role is required.", "ROLE_REQUIRED"); + try + { + return ClinicalRoleExtensions.FromDbString(role.Trim().ToUpperInvariant()); + } + catch (ArgumentOutOfRangeException) + { + throw new ValidationException( + "Role must be one of: NURSE, PHYSICIAN, ADMIN, INTEGRATION.", + "INVALID_ROLE"); + } + } +} diff --git a/VigilCareClinicalAPI/VigilCareClinicalAPI.csproj b/VigilCareClinicalAPI/VigilCareClinicalAPI.csproj index 8725d2f..6e835e6 100644 --- a/VigilCareClinicalAPI/VigilCareClinicalAPI.csproj +++ b/VigilCareClinicalAPI/VigilCareClinicalAPI.csproj @@ -15,6 +15,7 @@ + diff --git a/VigilCareClinicalAPI/appsettings.json b/VigilCareClinicalAPI/appsettings.json index 8cab89b..616cc52 100644 --- a/VigilCareClinicalAPI/appsettings.json +++ b/VigilCareClinicalAPI/appsettings.json @@ -210,5 +210,9 @@ }, "ClinicalSync": { "SuppressPagingForSyncedAlerts": true + }, + "GatewayMonitoring": { + "StaleThresholdMinutes": 10, + "PollIntervalMinutes": 5 } } diff --git a/docs/dashboard-gap-analysis.md b/docs/dashboard-gap-analysis.md index 1b38133..9731094 100644 --- a/docs/dashboard-gap-analysis.md +++ b/docs/dashboard-gap-analysis.md @@ -19,6 +19,22 @@ Each item includes **who needs it** (clinical role), **why** it matters for pati --- +## Implementation progress + +**Last updated:** June 2026 + +| Status | Count | Items | +|--------|------:|-------| +| **Done** | 22 | #1–18, #20–23, #25 | +| **Partial** | 1 | #26 (core WCAG improvements shipped; full audit not completed) | +| **Open** | 5 | #19, #24, #27, #28 | + +**Recently completed (dashboard phases 22–23):** scoring history charts (SOFA, GCS, qSOFA), patient banner, encounter timeline, medication chart markers, ward table clinical columns with sort/search, sepsis board, department overview, critical alert banner, handoff report, discharge summary panel, vitals/GCS entry, alert role context on acknowledge, threshold management, user management (`UsersController` + UI), audit log viewer, reconciliation dashboard, role-based navigation, dark-mode chart theming (`useChartTheme.js`), mobile patient-detail layout with collapsible sections, and baseline WCAG improvements (severity icons, chart `aria-label`s, modal focus trap, skip link, ward table keyboard nav). + +**Still open:** gateway status dashboard (#19), alert-triage keyboard shortcuts (#24), Grafana expansion (#27), real-time push (#28). + +--- + # Part A — Patient Safety & Bedside Decision Support --- @@ -902,32 +918,32 @@ Real-time push reduces alert notification latency from 5-10 seconds to sub-secon | # | Issue | Priority | Part | Backend exists? | Status | |---|-------|----------|------|----------------|--------| -| 1 | No SOFA trend chart | P0 | A | Yes | Open | -| 2 | No GCS trend chart / history endpoint | P0 | A | Partial (no history endpoint) | Open | -| 3 | No qSOFA history view | P0 | A | No (Redis-only, no persistence) | Open | -| 4 | No medication overlay on vital charts | P0 | A | Yes | Open | -| 5 | No encounter timeline view | P1 | A | Yes | Open | -| 6 | No patient demographics / allergy banner | P1 | A | Yes | Open | -| 7 | Ward table missing SOFA, GCS, staleness | P1 | B | Partial (DTO needs extension) | Open | -| 8 | No sepsis bundle compliance board | P2 | B | Yes | Open | -| 9 | No department overview with aggregates | P2 | B | Partial | Open | -| 10 | No sort controls on ward table | P2 | B | N/A (client-side) | Open | -| 11 | No search/filter on ward table | P2 | B | Yes (analytics search) | Open | -| 12 | No critical alert notification/sound | P1 | C | No (needs push or client detect) | Open | -| 13 | No handoff/shift summary report | P1 | C | Yes (data available) | Open | -| 14 | No discharge summary view | P2 | C | Partial (needs download endpoint) | Open | -| 15 | Alert ack lacks role context | P3 | C | Partial | Open | -| 16 | No vitals entry form | P3 | C | Yes | Open | -| 17 | No threshold management UI | P2 | D | Yes | Open | -| 18 | No user management UI | P2 | D | No (needs backend endpoints) | Open | +| 1 | No SOFA trend chart | P0 | A | Yes | **Done** — `SofaHistory.vue` | +| 2 | No GCS trend chart / history endpoint | P0 | A | Yes | **Done** — `GcsHistory.vue`, `GET /gcs/history` | +| 3 | No qSOFA history view | P0 | A | Yes | **Done** — `QsofaHistory.vue`, `qsofa_evaluations` + `GET /qsofa/history` | +| 4 | No medication overlay on vital charts | P0 | A | Yes | **Done** — `medicationMarkerPlugin.js` | +| 5 | No encounter timeline view | P1 | A | Yes | **Done** — `EncounterTimeline.vue` | +| 6 | No patient demographics / allergy banner | P1 | A | Yes | **Done** — `PatientBanner.vue` | +| 7 | Ward table missing SOFA, GCS, staleness | P1 | B | Yes | **Done** — extended `WardEncounterSummary` + `PatientRow` | +| 8 | No sepsis bundle compliance board | P2 | B | Yes | **Done** — `SepsisBoardView.vue` | +| 9 | No department overview with aggregates | P2 | B | Yes | **Done** — `DepartmentOverviewView.vue` | +| 10 | No sort controls on ward table | P2 | B | N/A (client-side) | **Done** — `wardSort.js`, `SortableHeader` | +| 11 | No search/filter on ward table | P2 | B | Yes (analytics search) | **Done** — `WardToolbar.vue` | +| 12 | No critical alert notification/sound | P1 | C | Partial (client detect) | **Done** — `CriticalAlertBanner.vue`, sound mute toggle | +| 13 | No handoff/shift summary report | P1 | C | Yes (data available) | **Done** — `HandoffReport.vue` | +| 14 | No discharge summary view | P2 | C | Yes | **Done** — `DischargeSummaryPanel.vue`, `GET /discharge-summary` | +| 15 | Alert ack lacks role context | P3 | C | Yes | **Done** — `alertAcknowledge.js`, `AcknowledgeModal.vue` | +| 16 | No vitals entry form | P3 | C | Yes | **Done** — `VitalsEntryForm.vue`, `GcsEntryForm.vue` | +| 17 | No threshold management UI | P2 | D | Yes | **Done** — `ThresholdManagementView.vue` | +| 18 | No user management UI | P2 | D | Yes | **Done** — `UsersController`, `UserManagementView.vue` | | 19 | No gateway status dashboard | P2 | D | Yes | Open | -| 20 | No audit log viewer | P4 | D | Yes | Open | -| 21 | No reconciliation dashboard | P4 | D | Yes | Open | -| 22 | No role-based navigation | P3 | E | N/A (frontend-only) | Open | -| 23 | Dark mode chart inconsistency | P3 | E | N/A | Open | +| 20 | No audit log viewer | P4 | D | Yes | **Done** — `AuditLogView.vue` | +| 21 | No reconciliation dashboard | P4 | D | Yes | **Done** — `ReconciliationView.vue` | +| 22 | No role-based navigation | P3 | E | N/A (frontend-only) | **Done** — `roleAccess.js`, route guards | +| 23 | Dark mode chart inconsistency | P3 | E | N/A | **Done** — `useChartTheme.js` | | 24 | No keyboard shortcuts | P3 | E | N/A | Open | -| 25 | No mobile optimization for patient detail | P3 | E | N/A | Open | -| 26 | No accessibility (WCAG) compliance | P5 | E | N/A | Open | +| 25 | No mobile optimization for patient detail | P3 | E | N/A | **Done** — collapsible sections, single-column mobile layout | +| 26 | No accessibility (WCAG) compliance | P5 | E | N/A | **Partial** — severity icons, chart labels, modal focus trap, skip link, ward keyboard nav | | 27 | Grafana dashboard incomplete | P4 | F | Partial (needs more metrics) | Open | | 28 | No real-time push (polling only) | P4 | F | No (needs SSE/SignalR) | Open | @@ -988,32 +1004,33 @@ flowchart TD ### Sprint-sized batches -| Batch | Items | Outcome | -|-------|-------|---------| -| **1 — Bedside Decision Support** | P0 SOFA chart, P0 GCS history + chart, P0 medication overlay, P1 patient banner, P1 timeline | Physicians and nurses see full scoring history and medication context for clinical decisions | -| **2 — Ward Awareness** | P0 qSOFA history, P1 ward table columns, P2 sort/search, P1 critical alert notification, P2 sepsis board | Charge nurses have complete situational awareness; critical alerts demand attention | -| **3 — Clinical Workflows** | P1 handoff report, P2 discharge summary, P3 vitals entry form, P3 alert role context | Nurses can chart vitals in-app; shift handoff is printable; discharge documents accessible | -| **4 — Admin Tools** | P3 role-based nav, P2 threshold management, P2 gateway status, P2 user management, P2 department overview | Admins manage thresholds without API calls; IT monitors gateway health; navigation is role-scoped | -| **5 — Polish & Infrastructure** | P3 dark mode charts, P3 keyboard shortcuts, P3 mobile optimization, P4 audit/reconciliation views, P4 Grafana, P5 WCAG | Night-shift readability, power-user efficiency, compliance audit trail, monitoring depth | -| **6 — Real-Time** | P4 SSE/SignalR push | Sub-second alert notification, reduced polling load | +| Batch | Items | Outcome | Status | +|-------|-------|---------|--------| +| **1 — Bedside Decision Support** | P0 SOFA chart, P0 GCS history + chart, P0 medication overlay, P1 patient banner, P1 timeline | Physicians and nurses see full scoring history and medication context for clinical decisions | **Done** | +| **2 — Ward Awareness** | P0 qSOFA history, P1 ward table columns, P2 sort/search, P1 critical alert notification, P2 sepsis board | Charge nurses have complete situational awareness; critical alerts demand attention | **Done** (department overview also shipped) | +| **3 — Clinical Workflows** | P1 handoff report, P2 discharge summary, P3 vitals entry form, P3 alert role context | Nurses can chart vitals in-app; shift handoff is printable; discharge documents accessible | **Done** | +| **4 — Admin Tools** | P3 role-based nav, P2 threshold management, P2 gateway status, P2 user management, P2 department overview | Admins manage thresholds without API calls; IT monitors gateway health; navigation is role-scoped | **Partial** — gateway status UI still open | +| **5 — Polish & Infrastructure** | P3 dark mode charts, P3 keyboard shortcuts, P3 mobile optimization, P4 audit/reconciliation views, P4 Grafana, P5 WCAG | Night-shift readability, power-user efficiency, compliance audit trail, monitoring depth | **Partial** — shortcuts, Grafana, full WCAG audit open | +| **6 — Real-Time** | P4 SSE/SignalR push | Sub-second alert notification, reduced polling load | Open | --- ## Backend work required -Most dashboard gaps are **frontend-only** — the API endpoints already exist but the dashboard does not consume them. The following items require backend changes: +Most dashboard gaps are **frontend-only** — the API endpoints already exist but the dashboard does not consume them. The following items required or still require backend changes: -| Item | Backend work needed | -|------|-------------------| -| GCS history chart | New `GET /encounters/{id}/gcs/history` endpoint | -| qSOFA history | Decide persistence strategy (Redis → DB) + new history endpoint | -| Ward table columns | Extend `WardEncounterSummary` DTO with SOFA/GCS/staleness | -| Discharge summary view | New `GET /encounters/{id}/discharge-summary` download endpoint | -| User management | New `UsersController` with CRUD endpoints | -| Real-time push | New SSE endpoint or SignalR hub | -| Grafana expansion | Depends on platform gap P5 metrics being implemented first | +| Item | Backend work needed | Status | +|------|---------------------|--------| +| GCS history chart | `GET /encounters/{id}/gcs/history` endpoint | **Done** | +| qSOFA history | `qsofa_evaluations` table + `GET /qsofa/history` | **Done** | +| Ward table columns | Extend `WardEncounterSummary` DTO with SOFA/GCS/staleness | **Done** | +| Discharge summary view | `GET /encounters/{id}/discharge-summary` + content download | **Done** | +| User management | `UsersController` with list/create/patch | **Done** | +| Gateway status dashboard | Fleet/summary APIs exist (Phase 23); dashboard UI not built | Open (frontend) | +| Real-time push | New SSE endpoint or SignalR hub | Open | +| Grafana expansion | Depends on platform gap P5 metrics being implemented first | Open | -All other items can be built against existing API endpoints. +All other closed items were built against existing API endpoints. --- diff --git a/docs/docs/docker-compose-usage-and-troubleshooting.md b/docs/docs/docker-compose-usage-and-troubleshooting.md new file mode 100644 index 0000000..7f9afca --- /dev/null +++ b/docs/docs/docker-compose-usage-and-troubleshooting.md @@ -0,0 +1,10 @@ +## Climate resilience demo + +Run the network partition walkthrough: + + export ADMIN_JWT= + export GATEWAY_JWT= + ./scripts/demo-network-partition.sh + +Expected flow: gateway ONLINE → partition → observation buffered locally → +central shows DEGRADED/OFFLINE + buffer depth > 0 → heal → ONLINE + buffer 0. \ No newline at end of file diff --git a/infra/grafana/dashboards/vigilcare.json b/infra/grafana/dashboards/vigilcare.json index 10ba06b..7a3ee2f 100644 --- a/infra/grafana/dashboards/vigilcare.json +++ b/infra/grafana/dashboards/vigilcare.json @@ -192,6 +192,114 @@ "fields": "" } } + }, + { + "id": 9, + "type": "stat", + "title": "Ward Gateways Offline", + "description": "Gateways in DEGRADED or OFFLINE status", + "gridPos": { "x": 0, "y": 16, "w": 6, "h": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(ward_gateways_offline_gauge)", + "legendFormat": "offline/degraded", + "refId": "A" + } + ], + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 2 } + ] + } + }, + "overrides": [] + } + }, + { + "id": 10, + "type": "bargauge", + "title": "Gateway Buffer Depth", + "gridPos": { "x": 6, "y": 16, "w": 12, "h": 6 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "ward_gateway_buffer_depth", + "legendFormat": "{{gateway_code}} / {{department}}", + "refId": "A" + } + ] + }, + { + "id": 11, + "type": "timeseries", + "title": "Clinical Sync Batches (5m rate)", + "gridPos": { "x": 18, "y": 16, "w": 6, "h": 6 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(clinical_sync_batches_total[5m])", + "legendFormat": "{{status}}", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.95, rate(clinical_sync_batch_duration_seconds_bucket[5m]))", + "legendFormat": "p95 duration", + "refId": "B" + } + ] + }, + { + "id": 12, + "type": "stat", + "title": "Total Sync Backlog (buffer depth sum)", + "gridPos": { "x": 0, "y": 20, "w": 6, "h": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(ward_gateway_buffer_depth)", + "legendFormat": "pending events", + "refId": "A" + } + ], + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 100 }, + { "color": "red", "value": 500 } + ] + } + }, + "overrides": [] + } } ] } diff --git a/scripts/demo-network-partition.sh b/scripts/demo-network-partition.sh new file mode 100755 index 0000000..ac1b80d --- /dev/null +++ b/scripts/demo-network-partition.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +CENTRAL="${CENTRAL_URL:-http://localhost:5080}" +GATEWAY="${GATEWAY_URL:-http://localhost:5081}" +JWT="${ADMIN_JWT:?Set ADMIN_JWT to a valid admin bearer token}" +GATEWAY_JWT="${GATEWAY_JWT:?Set GATEWAY_JWT to a valid gateway dashboard JWT}" + +echo "=== VigilCare climate resilience demo ===" + +echo "==> 1. Full stack up" +docker compose --profile full --profile ward-gateway up -d +sleep 30 + +echo "==> 2. Baseline — gateway ONLINE" +curl -sf "$CENTRAL/api/v1/operations/gateways" \ + -H "Authorization: Bearer $JWT" \ + | jq '.data[] | {code: .gatewayCode, status, buffer: .reportedBufferDepth}' + +echo "==> 3. Partition — disconnect gateway from central network" +GATEWAY_CONTAINER=$(docker ps -qf name=ward-gateway-api) +NETWORK=$(docker network ls --format '{{.Name}}' | grep vigilcare | head -1) +docker network disconnect "$NETWORK" "$GATEWAY_CONTAINER" 2>/dev/null || true + +echo "Partition active — posting observation to gateway..." +ENCOUNTER_ID=$(curl -sf "$GATEWAY/api/v1/encounters?status=ACTIVE&department=ICU" \ + -H "Authorization: Bearer $GATEWAY_JWT" | jq -r '.data.items[0].id') + +curl -sf -X POST "$GATEWAY/api/v1/encounters/$ENCOUNTER_ID/observations" \ + -H "Authorization: Bearer $GATEWAY_JWT" \ + -H "Content-Type: application/json" \ + -d "{\"observationCode\":\"HEART_RATE\",\"value\":165,\"unit\":\"bpm\",\"source\":\"DEVICE\",\"recordedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"idempotencyKey\":\"partition-demo-$(date +%s)\"}" + +sleep 15 + +echo "==> 4. Fleet status during partition" +curl -sf "$CENTRAL/api/v1/operations/gateways" \ + -H "Authorization: Bearer $JWT" \ + | jq '.data[] | {code: .gatewayCode, status, buffer: .reportedBufferDepth, minutes: .minutesSinceHeartbeat}' + +echo "==> 5. Heal partition" +docker network connect "$NETWORK" "$GATEWAY_CONTAINER" +echo "Waiting for sync (60s)..." +sleep 60 + +echo "==> 6. Recovery — ONLINE + buffer 0" +curl -sf "$CENTRAL/api/v1/operations/gateways" \ + -H "Authorization: Bearer $JWT" \ + | jq '.data[] | {code: .gatewayCode, status, buffer: .reportedBufferDepth}' + +echo "=== Demo complete ===" \ No newline at end of file diff --git a/scripts/run-phase23-verification.sh b/scripts/run-phase23-verification.sh new file mode 100755 index 0000000..5e4e739 --- /dev/null +++ b/scripts/run-phase23-verification.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +echo "==> API integration tests" +dotnet test VigilCareClinicalAPI.Tests --filter "FullyQualifiedName~OperationsApi" + +echo "==> Vitest ops view" +cd vigilcare-dashboard && npm run test -- GatewayOperations.spec.js && cd .. + +echo "==> Optional partition demo (skip with SKIP_PARTITION=1)" +if [[ "${SKIP_PARTITION:-0}" != "1" ]]; then + ./scripts/demo-network-partition.sh +fi + +echo "==> Grafana checklist" +echo " - Open http://localhost:3101 (admin/admin)" +echo " - Dashboard: VigilCare Clinical" +echo " - Verify panels: Ward Gateways Offline, Gateway Buffer Depth," +echo " Clinical Sync Batches, Total Sync Backlog" +echo " - Export snapshot via Share → Snapshot" + +echo "Phase 23 verification passed." \ No newline at end of file diff --git a/vigilcare-dashboard/src/App.vue b/vigilcare-dashboard/src/App.vue index fe1f16b..8c9695e 100644 --- a/vigilcare-dashboard/src/App.vue +++ b/vigilcare-dashboard/src/App.vue @@ -2,12 +2,14 @@ import { computed } from 'vue' import { useRoute } from 'vue-router' import AppShell from '@/components/layout/AppShell.vue' +import DegradedModeBanner from '@/components/DegradedModeBanner.vue' const route = useRoute() const useShell = computed(() => !route.meta.public)