diff --git a/greenfield/.bun-browser-test-timings.json b/greenfield/.bun-browser-test-timings.json index 04dd4a946..d73bbca93 100644 --- a/greenfield/.bun-browser-test-timings.json +++ b/greenfield/.bun-browser-test-timings.json @@ -1,80 +1,85 @@ { "files": { - "src/browser/security/AccountSecurityRoute.test.tsx": 9458, - "src/browser/jobs/JobsRoute.test.tsx": 9431, - "src/browser/jobs/JobRunBrowser.test.tsx": 7309, - "src/browser/notifications/NotificationCenter.test.tsx": 5373, - "src/browser/tasks/TaskBoardRoute.test.tsx": 3420, - "src/browser/auth/LoginRoute.test.tsx": 3184, - "src/browser/monitoring/MonitoringRoutes.test.tsx": 2495, - "src/browser/agents/AgentsRoute.test.tsx": 2206, - "src/browser/jobs/ScheduleDetailForm.test.tsx": 2041, - "src/browser/overview/OverviewRoute.test.tsx": 1384, - "src/browser/auth/AuthenticatedBrowserCacheBoundary.test.tsx": 1084, - "src/browser/jobs/ScheduleEditor.test.tsx": 1041, - "src/browser/jobs/ScheduleDetailStateVersion.test.tsx": 1026, - "src/browser/ui/DateTimePicker.test.tsx": 989, - "src/browser/main.test.tsx": 960, - "src/browser/cache/cacheMutations.test.tsx": 938, - "src/browser/ui/TimePicker.test.tsx": 938, - "src/browser/auth/AuthenticatedSessionActivity.test.tsx": 920, - "src/browser/jobs/ScheduleDetailStateDisable.test.tsx": 873, - "src/browser/ui/Combobox.test.tsx": 863, - "src/browser/jobs/ScheduleDetailStateErrors.test.tsx": 838, - "src/browser/ui/DatePicker.test.tsx": 821, - "src/browser/auth/AuthenticationBoundary.test.tsx": 802, - "src/browser/jobs/ScheduleDetailStateCopy.test.tsx": 794, - "src/browser/jobs/JobRunDetail.test.tsx": 783, - "src/browser/jobs/ScheduleDetailStateReplay.test.tsx": 778, - "src/browser/auth/useAuthenticatedMutationBoundary.test.tsx": 757, - "src/browser/security/AutomationPrincipalCard.test.tsx": 705, - "src/browser/ui/Select.test.tsx": 652, - "src/browser/ui/Tabs.test.tsx": 646, - "src/browser/application.test.tsx": 643, - "src/browser/ui/Button.test.tsx": 591, - "src/browser/ui/RadioGroup.test.tsx": 513, - "src/browser/jobs/JobQueuePanel.test.tsx": 506, - "src/browser/jobs/JobRunTable.test.tsx": 495, - "src/browser/notifications/useNotificationRealtimeInvalidation.test.tsx": 490, - "src/browser/monitoring/useMonitoringRealtimeInvalidation.test.tsx": 485, - "src/browser/ui/DropdownMenu.test.tsx": 459, - "src/browser/jobs/jobRouteSearch.test.ts": 453, - "src/browser/monitoring/IncidentTable.test.tsx": 448, - "src/browser/ui/Popover.test.tsx": 433, - "src/browser/ui/ExternalLink.test.tsx": 423, - "src/browser/ui/Alert.test.tsx": 414, - "src/browser/cache/CacheStatusTable.test.tsx": 412, - "src/browser/api/useRealtimeQueryInvalidation.test.tsx": 407, - "src/browser/tasks/useTaskRealtimeInvalidation.test.tsx": 395, - "src/browser/ui/Switch.test.tsx": 391, - "src/browser/monitoring/monitoringQueries.test.ts": 384, - "src/browser/jobs/jobQueries.test.ts": 377, - "src/browser/jobs/useJobRealtimeInvalidation.test.tsx": 375, - "src/browser/api/trpcClient.test.ts": 360, - "src/browser/ui/DataTable.test.tsx": 352, - "src/browser/api/realtimeHub.test.ts": 346, - "src/browser/data/dashboardCollections.test.ts": 344, - "src/browser/jobs/jobMutations.test.tsx": 340, - "src/browser/cache/cacheQueries.test.ts": 338, - "src/browser/cache/cachePresentation.test.ts": 337, - "src/browser/jobs/scheduleEditorForm.test.ts": 321, - "src/browser/notifications/notificationCollection.test.ts": 318, - "src/browser/lib/formatDateTime.test.ts": 310, - "src/browser/notifications/notificationQueries.test.ts": 304, - "src/browser/notifications/notificationMutations.test.ts": 299, - "src/browser/security/webauthn/webauthnClient.test.ts": 295, - "src/browser/cache/useCacheRealtimeInvalidation.test.tsx": 293, - "src/browser/jobs/ScheduleTable.test.tsx": 289, - "src/browser/ui/LoadingState.test.tsx": 283, - "src/browser/ui/Fieldset.test.tsx": 281, - "src/browser/cache/SystemHostCard.test.tsx": 271, - "src/browser/auth/authQueries.test.ts": 261, - "src/browser/api/realtimeClient.test.ts": 251, - "src/browser/security/issuedAutomationToken.test.ts": 241, - "src/browser/tasks/taskEditorForm.test.ts": 239, - "src/browser/tasks/taskQueries.test.ts": 234, - "src/browser/test/setup.test.ts": 227, - "src/browser/tasks/TaskBoard.test.ts": 224 + "src/browser/security/AccountSecurityRoute.test.tsx": 8269, + "src/browser/jobs/JobsRoute.test.tsx": 7680, + "src/browser/jobs/JobRunBrowser.test.tsx": 6488, + "src/browser/notifications/NotificationCenter.test.tsx": 3719, + "src/browser/tasks/TaskBoardRoute.test.tsx": 3180, + "src/browser/auth/LoginRoute.test.tsx": 2772, + "src/browser/monitoring/MonitoringRoutes.test.tsx": 2360, + "src/browser/agents/AgentsRoute.test.tsx": 2043, + "src/browser/jobs/ScheduleDetailForm.test.tsx": 1477, + "src/browser/overview/OverviewRoute.test.tsx": 1365, + "src/browser/jobs/ScheduleDetailStateDisable.test.tsx": 1285, + "src/browser/ui/Combobox.test.tsx": 1117, + "src/browser/auth/AuthenticatedBrowserCacheBoundary.test.tsx": 1040, + "src/browser/ui/DateTimePicker.test.tsx": 1000, + "src/browser/jobs/ScheduleDetailStateVersion.test.tsx": 984, + "src/browser/ui/DatePicker.test.tsx": 912, + "src/browser/ui/TimePicker.test.tsx": 910, + "src/browser/auth/AuthenticatedSessionActivity.test.tsx": 831, + "src/browser/jobs/ScheduleDetailStateErrors.test.tsx": 761, + "src/browser/auth/AuthenticationBoundary.test.tsx": 718, + "src/browser/application.test.tsx": 691, + "src/browser/ui/DropdownMenu.test.tsx": 690, + "src/browser/jobs/ScheduleEditor.test.tsx": 680, + "src/browser/ui/Popover.test.tsx": 680, + "src/browser/main.test.tsx": 640, + "src/browser/security/AutomationPrincipalCard.test.tsx": 640, + "src/browser/jobs/JobRunDetail.test.tsx": 634, + "src/browser/jobs/ScheduleDetailStateReplay.test.tsx": 631, + "src/browser/auth/useAuthenticatedMutationBoundary.test.tsx": 630, + "src/browser/jobs/jobMutations.test.tsx": 617, + "src/browser/jobs/JobRunTable.test.tsx": 580, + "src/browser/jobs/JobQueuePanel.test.tsx": 534, + "src/browser/monitoring/useMonitoringRealtimeInvalidation.test.tsx": 529, + "src/browser/ui/Select.test.tsx": 526, + "src/browser/jobs/ScheduleDetailStateCopy.test.tsx": 514, + "src/browser/jobs/scheduleEditorForm.test.ts": 514, + "src/browser/overview/SystemMetricsCards.test.tsx": 500, + "src/browser/tasks/useTaskRealtimeInvalidation.test.tsx": 494, + "src/browser/lib/formatDateTime.test.ts": 479, + "src/browser/cache/CacheStatusTable.test.tsx": 451, + "src/browser/jobs/useJobRealtimeInvalidation.test.tsx": 443, + "src/browser/ui/RadioGroup.test.tsx": 434, + "src/browser/notifications/useNotificationRealtimeInvalidation.test.tsx": 427, + "src/browser/cache/cacheMutations.test.tsx": 422, + "src/browser/ui/LoadingState.test.tsx": 422, + "src/browser/ui/Tabs.test.tsx": 414, + "src/browser/api/trpcClient.test.ts": 413, + "src/browser/ui/DataTable.test.tsx": 410, + "src/browser/ui/Fieldset.test.tsx": 403, + "src/browser/tasks/taskEditorForm.test.ts": 394, + "src/browser/ui/Alert.test.tsx": 394, + "src/browser/api/realtimeHub.test.ts": 387, + "src/browser/tasks/TaskBoard.test.ts": 374, + "src/browser/lib/formatMeasurements.test.ts": 373, + "src/browser/test/setup.test.ts": 370, + "src/browser/tasks/taskQueries.test.ts": 367, + "src/browser/jobs/ScheduleTable.test.tsx": 347, + "src/browser/monitoring/IncidentTable.test.tsx": 337, + "src/browser/ui/Switch.test.tsx": 337, + "src/browser/ui/Button.test.tsx": 333, + "src/browser/cache/SystemHostCard.test.tsx": 329, + "src/browser/jobs/jobQueries.test.ts": 322, + "src/browser/api/useRealtimeQueryInvalidation.test.tsx": 316, + "src/browser/jobs/jobRouteSearch.test.ts": 306, + "src/browser/data/dashboardCollections.test.ts": 297, + "src/browser/monitoring/monitoringQueries.test.ts": 290, + "src/browser/notifications/notificationMutations.test.ts": 290, + "src/browser/ui/ExternalLink.test.tsx": 290, + "src/browser/cache/cacheQueries.test.ts": 284, + "src/browser/notifications/notificationCollection.test.ts": 283, + "src/browser/cache/useCacheRealtimeInvalidation.test.tsx": 279, + "src/browser/api/realtimeClient.test.ts": 265, + "src/browser/security/issuedAutomationToken.test.ts": 264, + "src/browser/notifications/notificationQueries.test.ts": 259, + "src/browser/ui/MetricCard.test.tsx": 256, + "src/browser/overview/systemMetricsPresentation.test.ts": 252, + "src/browser/auth/authQueries.test.ts": 249, + "src/browser/overview/systemMetricsQueries.test.ts": 248, + "src/browser/cache/cachePresentation.test.ts": 235, + "src/browser/security/webauthn/webauthnClient.test.ts": 228 }, "version": 1 } diff --git a/greenfield/.bun-test-timings.json b/greenfield/.bun-test-timings.json index 3ab145ad9..6b598970f 100644 --- a/greenfield/.bun-test-timings.json +++ b/greenfield/.bun-test-timings.json @@ -1,278 +1,282 @@ { "files": { - "scripts/delivery/productionReleaseActivation.test.ts": 42890, - "src/test/integration/delivery/productionReleaseLifecycle.test.ts": 36635, - "scripts/sourceBoundaries/lintConfiguration.test.ts": 22497, - "scripts/delivery/buildBrowser.test.ts": 15008, - "scripts/sourceBoundaries/policy.test.ts": 12794, - "src/server/test/system/serverMfaAuthentication.test.ts": 10611, - "scripts/delivery/installProductionSystemdUnits.test.ts": 8413, - "src/server/test/system/serverWebAuthnAuthentication.test.ts": 6966, - "scripts/delivery/systemdProductionServices.test.ts": 5820, - "scripts/delivery/productionReleasePublication.test.ts": 5063, - "src/server/test/system/serverAutomationSecurityLeaseInvalidation.test.ts": 4840, - "src/test/integration/transport/topology/rollingReleaseSse.test.ts": 4215, - "src/server/database/migrations/jobsSchema.test.ts": 4000, - "src/server/domains/security/mfa/loginLifecycle.webAuthn.test.ts": 3853, - "src/server/domains/security/automation/lifecyclePrincipal.test.ts": 3647, - "scripts/delivery/releaseIdentity.test.ts": 3510, - "src/test/integration/transport/trpc/trpcFetchSse.test.ts": 3469, - "src/server/domains/security/mfa/accountLifecycle.webAuthn.test.ts": 3025, - "src/server/domains/jobs/repository.test.ts": 2976, - "src/server/test/system/serverGatewayCredentialVerification.test.ts": 2776, - "src/server/database/runtime/databaseService.test.ts": 2436, - "scripts/delivery/buildRelease.test.ts": 2306, - "src/test/integration/build/frontendBuildScenario.test.ts": 2082, - "src/test/integration/transport/topology/httpsReverseProxy.test.ts": 1949, - "src/server/test/system/serverAutomationSecurity.test.ts": 1850, - "src/test/integration/shutdown/completeShutdownScenario.test.ts": 1756, - "src/server/domains/monitoring/catalogService.test.ts": 1724, - "src/server/domains/jobs/service.test.ts": 1717, - "src/app/dashboardServer.test.ts": 1707, - "src/server/domains/security/authenticationLifecycle.sessions.test.ts": 1566, - "src/server/domains/tasks/service.test.ts": 1470, - "src/server/domains/cache/repository.test.ts": 1441, - "scripts/delivery/databaseTransitionFilesystem.test.ts": 1413, - "src/server/platform/runtime/applicationRuntime.test.ts": 1410, - "src/server/database/migrations/migrationLedgerValidation.test.ts": 1370, - "src/server/database/migrations/migrationGraph.test.ts": 1353, - "src/test/integration/outbox/sqliteOutboxScenario.test.ts": 1302, - "src/server/domains/security/mfa/accountLifecycle.proofs.test.ts": 1274, - "src/server/domains/security/authenticationLifecycle.bootstrap.test.ts": 1264, - "src/test/integration/resources/pausedTlsSseClient.test.ts": 1251, - "src/test/integration/websocket/nativeWebSocketTransport.test.ts": 1237, - "src/server/domains/security/mfa/loginLifecycle.totp.test.ts": 1223, - "src/server/platform/realtime/eventPumpSubscriptionRetention.test.ts": 1197, - "src/server/domains/security/requestAuthenticationAutomation.test.ts": 1136, - "src/server/domains/security/authenticationLifecycle.rateLimit.test.ts": 1069, - "src/server/domains/agents/service.test.ts": 1057, - "src/server/platform/realtime/eventPumpSubscriptionBackpressure.test.ts": 1015, - "src/server/domains/jobs/workerSystem.test.ts": 962, - "src/server/database/migrations/auditEventsSchema.test.ts": 955, - "src/server/database/runtime/databaseSnapshot.test.ts": 855, - "src/server/platform/runtime/dashboardApplicationRuntime.test.ts": 841, - "src/server/domains/tasks/taskNotificationQueue.test.ts": 825, - "src/server/domains/security/authenticationLifecycle.password.test.ts": 808, - "src/server/platform/realtime/eventPumpServiceFairness.test.ts": 800, - "src/server/test/system/serverAutomationSecurityLostResponse.test.ts": 785, - "src/server/domains/security/password.test.ts": 772, - "src/server/domains/agents/procedures.test.ts": 771, - "src/server/domains/security/securityAuditLifecycle.test.ts": 765, - "src/server/platform/realtime/eventPumpSubscriptionReplay.test.ts": 751, - "src/server/database/migrations/authenticationRateLimitSchema.test.ts": 745, - "src/server/domains/security/automation/lifecycleRepository.test.ts": 744, - "src/server/test/system/serverRealtime.test.ts": 714, - "src/server/domains/security/automation/lifecycleCredential.test.ts": 709, - "src/server/domains/monitoring/procedures.test.ts": 686, - "src/server/domains/security/requestAuthenticationSession.test.ts": 679, - "src/server/domains/tasks/procedures.test.ts": 661, - "scripts/delivery/buildProcesses.test.ts": 642, - "src/test/integration/shutdown/shutdownServiceResources.test.ts": 608, - "src/server/database/migrations/monitoringSchema.test.ts": 603, - "src/server/domains/security/requestAuthenticationRepository.test.ts": 599, - "src/server/platform/realtime/eventPumpSubscriptionCancellation.test.ts": 581, - "src/app/dashboardServerProcess.test.ts": 566, - "src/server/platform/realtime/eventPumpServiceSubscription.test.ts": 552, - "src/server/test/system/serverFoundation.test.ts": 536, - "src/server/domains/security/mfa/accountLifecycle.factors.test.ts": 535, - "src/server/domains/security/mfa/lifecycleRepository.webAuthn.test.ts": 535, - "src/server/platform/configuration/webConfiguration.test.ts": 526, - "src/server/domains/jobs/procedures.test.ts": 514, - "src/server/domains/monitoring/serviceOrdering.test.ts": 514, - "src/server/domains/security/mfa/loginLifecycle.recovery.test.ts": 505, - "src/server/test/system/serverAuthenticationTransport.test.ts": 502, - "src/server/domains/security/authenticationWorkGate.webAuthn.test.ts": 500, - "src/server/domains/realtime/transport.test.ts": 497, - "src/server/domains/cache/service.test.ts": 495, - "src/server/domains/security/mfa/lifecycleRepository.test.ts": 492, - "src/server/domains/jobs/coordinator.test.ts": 484, - "src/server/domains/monitoring/serviceBoundary.test.ts": 482, - "scripts/sourceBoundaries/sourceDiscovery.test.ts": 470, - "src/server/database/migrations/securityIdentitySchema.baseline.test.ts": 470, - "scripts/sourceBoundaries/importGraph.test.ts": 467, - "src/server/platform/realtime/eventStore.test.ts": 461, - "src/server/domains/monitoring/serviceLifecycle.test.ts": 457, - "src/server/platform/realtime/eventPumpPolling.test.ts": 457, - "src/server/domains/security/authenticationWorkGate.test.ts": 437, - "src/server/trpc/appRouter.test.ts": 434, - "src/server/database/validation/rowSchemas.test.ts": 419, - "src/server/platform/realtime/eventPumpServiceLifecycle.test.ts": 408, - "src/server/platform/realtime/renewableStreamLease.test.ts": 405, - "src/server/domains/security/procedures.test.ts": 403, - "src/server/test/system/serverAuthenticationResponses.test.ts": 392, - "src/server/domains/monitoring/normalization.test.ts": 386, - "src/test/parity/parityInventory.test.ts": 386, - "src/contracts/cache.test.ts": 379, - "src/server/domains/security/automation/procedures.test.ts": 378, - "src/server/test/contracts/trpcErrors.test.ts": 372, - "src/server/database/migrations/securityIdentitySchema.automation.test.ts": 368, - "src/server/domains/security/mfa/accountLifecycle.maintenance.test.ts": 368, - "src/server/domains/security/authPendingMfaRoutes.webAuthn.test.ts": 363, - "src/server/domains/monitoring/testSupport/services.test.ts": 362, - "src/server/platform/realtime/eventPumpServicePolling.test.ts": 358, - "src/server/trpc/context.test.ts": 355, - "src/server/trpc/procedureErrorPolicy.test.ts": 352, - "src/server/database/migrations/loadVerifiedMigrations.test.ts": 347, - "src/server/domains/cache/procedures.test.ts": 344, - "src/server/test/system/serverShutdown.test.ts": 344, - "src/server/domains/security/mfa/procedures.test.ts": 342, - "src/app/worker.test.ts": 341, - "scripts/sourceBoundaries/checkerIntegration.test.ts": 336, - "src/server/domains/realtime/errors.test.ts": 333, - "src/server/database/migrations/webauthnLifecycleSchema.test.ts": 331, - "src/app/trpcHttpHandler.test.ts": 330, - "scripts/delivery/productionActivationJournal.test.ts": 327, - "src/server/database/validation/cacheEntries.test.ts": 326, - "src/server/database/migrations/taskSchema.test.ts": 324, - "src/server/test/contracts/superjsonTransport.test.ts": 324, - "src/server/database/migrations/agentTaskRunsSchema.test.ts": 321, - "src/app/databaseMaintenance.test.ts": 316, - "src/server/domains/security/securityAuditProcedures.test.ts": 304, - "src/server/domains/security/mfa/loginLifecycle.pending.test.ts": 300, - "src/test/support/asyncCleanupStack.test.ts": 300, - "src/server/database/runtime/databasePath.test.ts": 298, - "src/server/database/migrations/securityIdentitySchema.browser.test.ts": 297, - "src/server/database/migrations/jsonObjectConstraints.test.ts": 295, - "src/server/domains/security/authenticationLifecycleRepository.test.ts": 292, - "src/server/platform/observability/effectLogger.test.ts": 287, - "src/worker/taskNotifications.test.ts": 284, - "src/server/domains/realtime/procedures.test.ts": 282, - "src/contracts/automationSecurity.test.ts": 279, - "scripts/runTestSuite.test.ts": 276, - "scripts/delivery/deploymentLease.test.ts": 274, - "src/server/domains/security/authenticationLifecycle.login.test.ts": 272, - "scripts/sourceBoundaries/boundaryConfiguration.test.ts": 258, - "src/server/domains/jobs/workerRuntime.test.ts": 255, - "src/server/database/validation/incidents.test.ts": 253, - "src/server/database/migrations/mfaLifecycleSchema.test.ts": 252, - "src/server/database/migrations/migrationApplicationTime.test.ts": 252, - "src/server/trpc/trpc.test.ts": 246, - "src/server/database/validation/authChallenges.test.ts": 242, - "src/server/database/migrations/realtimeSchema.test.ts": 232, - "src/server/database/validation/rowSchemaIntegration.test.ts": 223, - "src/server/domains/jobs/scheduleTime.test.ts": 223, - "src/server/domains/jobs/actionExecutors.test.ts": 213, - "scripts/sourceBoundaries/importTargetValidation.test.ts": 206, - "src/server/platform/errors/safeFailure.test.ts": 206, - "scripts/delivery/productionActivationState.test.ts": 200, - "src/server/domains/security/mfa/webauthn/adapter.test.ts": 199, - "src/test/integration/openclaw/sourceAudit.test.ts": 194, - "src/server/database/validation/taskRows.test.ts": 191, - "src/server/database/validation/automationPrincipals.test.ts": 188, - "src/server/platform/gateway/gatewayCredentialVerifier.test.ts": 186, - "src/server/database/schema/webauthnPersistence.test.ts": 185, - "scripts/delivery/productionStateFilesystem.test.ts": 182, - "src/test/integration/resourceBudgets/resourceBudgetOrchestration.test.ts": 180, - "src/server/platform/realtime/eventPumpContract.test.ts": 172, - "src/server/database/validation/notifications.test.ts": 168, - "scripts/documentation/artifacts.test.ts": 154, - "scripts/delivery/productionRuntime.test.ts": 152, - "src/test/integration/files/boundedFile.test.ts": 148, - "src/test/integration/resources/runSseMemoryEvidence.test.ts": 147, - "src/contracts/jobRealtime.test.ts": 144, - "scripts/delivery/activateProductionRelease.test.ts": 141, - "src/server/rawHttp/frontendAssets.test.ts": 133, - "src/server/database/validation/monitorRuns.test.ts": 132, - "scripts/buildSourceIdentity.test.ts": 131, - "scripts/delivery/productionDeliveryFilesystem.test.ts": 129, - "src/contracts/jobModel.test.ts": 129, - "src/contracts/events.test.ts": 128, - "src/app/trpcRequestPolicy.test.ts": 120, + "scripts/delivery/productionReleaseActivation.test.ts": 31468, + "src/test/integration/delivery/productionReleaseLifecycle.test.ts": 28877, + "scripts/sourceBoundaries/lintConfiguration.test.ts": 15339, + "scripts/sourceBoundaries/policy.test.ts": 14740, + "scripts/delivery/buildBrowser.test.ts": 11198, + "src/server/test/system/serverMfaAuthentication.test.ts": 11083, + "scripts/delivery/installProductionSystemdUnits.test.ts": 6891, + "scripts/delivery/systemdProductionServices.test.ts": 5148, + "src/server/test/system/serverWebAuthnAuthentication.test.ts": 5045, + "src/server/test/system/serverAutomationSecurityLeaseInvalidation.test.ts": 4907, + "scripts/delivery/productionReleasePublication.test.ts": 4767, + "scripts/delivery/releaseIdentity.test.ts": 4584, + "src/server/database/migrations/jobsSchema.test.ts": 4051, + "src/test/integration/transport/topology/rollingReleaseSse.test.ts": 3968, + "src/test/integration/transport/trpc/trpcFetchSse.test.ts": 3446, + "src/server/domains/jobs/repository.test.ts": 2865, + "src/test/integration/build/frontendBuildScenario.test.ts": 2574, + "src/server/domains/security/mfa/loginLifecycle.webAuthn.test.ts": 2377, + "src/server/database/runtime/databaseService.test.ts": 2353, + "src/server/domains/jobs/service.test.ts": 2320, + "scripts/delivery/buildRelease.test.ts": 2178, + "src/server/domains/security/authenticationLifecycle.sessions.test.ts": 2156, + "src/test/integration/transport/topology/httpsReverseProxy.test.ts": 2009, + "src/app/dashboardServer.test.ts": 1923, + "src/server/domains/cache/repository.test.ts": 1893, + "src/server/domains/tasks/service.test.ts": 1893, + "src/server/test/system/serverGatewayCredentialVerification.test.ts": 1737, + "src/server/domains/security/mfa/accountLifecycle.webAuthn.test.ts": 1728, + "src/server/test/system/serverAutomationSecurity.test.ts": 1726, + "src/server/domains/security/authenticationLifecycle.rateLimit.test.ts": 1651, + "src/server/domains/monitoring/catalogService.test.ts": 1615, + "src/server/domains/security/automation/lifecyclePrincipal.test.ts": 1570, + "scripts/delivery/databaseTransitionFilesystem.test.ts": 1554, + "src/test/integration/shutdown/completeShutdownScenario.test.ts": 1423, + "src/server/platform/realtime/eventPumpSubscriptionReplay.test.ts": 1411, + "src/server/domains/security/requestAuthenticationAutomation.test.ts": 1405, + "src/test/integration/resources/pausedTlsSseClient.test.ts": 1352, + "src/server/database/migrations/migrationLedgerValidation.test.ts": 1336, + "src/test/integration/websocket/nativeWebSocketTransport.test.ts": 1307, + "src/server/platform/runtime/dashboardApplicationRuntime.test.ts": 1288, + "src/server/platform/runtime/applicationRuntime.test.ts": 1284, + "src/server/domains/security/automation/lifecycleCredential.test.ts": 1258, + "src/server/test/system/serverFoundation.test.ts": 1227, + "src/server/domains/security/automation/lifecycleRepository.test.ts": 1171, + "src/server/domains/security/mfa/loginLifecycle.totp.test.ts": 1160, + "src/server/domains/security/authenticationLifecycle.bootstrap.test.ts": 1157, + "src/server/test/system/serverAutomationSecurityLostResponse.test.ts": 1123, + "src/server/database/migrations/migrationGraph.test.ts": 1101, + "src/server/database/migrations/taskSchema.test.ts": 1057, + "src/server/domains/security/mfa/accountLifecycle.proofs.test.ts": 1035, + "src/server/platform/realtime/eventPumpSubscriptionBackpressure.test.ts": 1021, + "src/test/integration/outbox/sqliteOutboxScenario.test.ts": 978, + "src/server/test/system/serverRealtime.test.ts": 967, + "src/server/domains/system/procedures.test.ts": 920, + "src/server/platform/realtime/eventPumpSubscriptionCancellation.test.ts": 875, + "src/server/database/runtime/databaseSnapshot.test.ts": 833, + "src/server/domains/security/authenticationLifecycle.password.test.ts": 765, + "src/server/database/migrations/authenticationRateLimitSchema.test.ts": 749, + "src/server/platform/realtime/eventStore.test.ts": 749, + "src/server/domains/agents/service.test.ts": 740, + "src/server/domains/security/password.test.ts": 729, + "src/server/database/migrations/auditEventsSchema.test.ts": 668, + "src/server/domains/security/mfa/accountLifecycle.maintenance.test.ts": 659, + "src/server/domains/security/mfa/loginLifecycle.pending.test.ts": 648, + "src/server/domains/security/requestAuthenticationSession.test.ts": 646, + "src/server/domains/security/securityAuditLifecycle.test.ts": 631, + "src/server/domains/security/automation/procedures.test.ts": 622, + "src/server/database/migrations/monitoringSchema.test.ts": 607, + "src/server/domains/security/mfa/lifecycleRepository.webAuthn.test.ts": 584, + "src/server/domains/monitoring/procedures.test.ts": 562, + "src/server/domains/security/mfa/accountLifecycle.factors.test.ts": 561, + "src/server/domains/tasks/procedures.test.ts": 543, + "src/server/database/migrations/securityIdentitySchema.browser.test.ts": 540, + "src/server/domains/jobs/workerSystem.test.ts": 532, + "src/server/domains/security/authPendingMfaRoutes.webAuthn.test.ts": 532, + "src/server/database/migrations/loadVerifiedMigrations.test.ts": 521, + "src/server/domains/agents/procedures.test.ts": 514, + "src/server/test/system/serverAuthenticationTransport.test.ts": 514, + "src/server/database/migrations/webauthnLifecycleSchema.test.ts": 508, + "src/server/domains/security/mfa/loginLifecycle.recovery.test.ts": 502, + "src/server/domains/security/authenticationWorkGate.webAuthn.test.ts": 501, + "scripts/delivery/buildProcesses.test.ts": 487, + "src/server/domains/security/mfa/lifecycleRepository.test.ts": 479, + "src/server/domains/monitoring/serviceOrdering.test.ts": 476, + "src/test/integration/shutdown/shutdownServiceResources.test.ts": 463, + "src/server/domains/monitoring/serviceBoundary.test.ts": 449, + "src/server/domains/jobs/coordinator.test.ts": 446, + "src/server/domains/security/requestAuthenticationRepository.test.ts": 443, + "src/server/domains/monitoring/serviceLifecycle.test.ts": 435, + "src/server/database/migrations/securityIdentitySchema.automation.test.ts": 431, + "src/server/domains/security/mfa/procedures.test.ts": 431, + "src/server/domains/cache/procedures.test.ts": 413, + "src/server/test/system/serverShutdown.test.ts": 407, + "scripts/sourceBoundaries/sourceDiscovery.test.ts": 394, + "src/server/platform/realtime/eventPumpSubscriptionRetention.test.ts": 391, + "src/app/trpcHttpHandler.test.ts": 388, + "src/app/worker.test.ts": 349, + "src/server/database/migrations/securityIdentitySchema.baseline.test.ts": 326, + "src/server/platform/realtime/eventPumpServicePolling.test.ts": 323, + "src/server/domains/security/procedures.test.ts": 322, + "src/server/domains/security/authenticationLifecycleRepository.test.ts": 318, + "src/server/platform/realtime/eventPumpPolling.test.ts": 308, + "src/server/database/migrations/agentTaskRunsSchema.test.ts": 303, + "src/server/domains/security/securityAuditProcedures.test.ts": 303, + "scripts/delivery/productionActivationJournal.test.ts": 299, + "src/app/dashboardServerProcess.test.ts": 297, + "src/server/database/migrations/mfaLifecycleSchema.test.ts": 295, + "src/server/trpc/appRouter.test.ts": 293, + "scripts/delivery/deploymentLease.test.ts": 281, + "src/server/test/system/serverAuthenticationResponses.test.ts": 280, + "scripts/delivery/activateProductionRelease.test.ts": 279, + "scripts/sourceBoundaries/boundaryConfiguration.test.ts": 277, + "src/server/domains/security/authenticationLifecycle.login.test.ts": 275, + "scripts/sourceBoundaries/importGraph.test.ts": 270, + "src/server/domains/security/authenticationWorkGate.test.ts": 265, + "src/server/domains/cache/service.test.ts": 255, + "src/server/domains/monitoring/normalization.test.ts": 252, + "scripts/sourceBoundaries/importTargetValidation.test.ts": 251, + "src/server/database/migrations/realtimeSchema.test.ts": 249, + "src/server/database/migrations/jsonObjectConstraints.test.ts": 246, + "src/server/platform/realtime/renewableStreamLease.test.ts": 246, + "scripts/buildSourceIdentity.test.ts": 240, + "scripts/sourceBoundaries/checkerIntegration.test.ts": 238, + "src/server/domains/monitoring/testSupport/services.test.ts": 236, + "src/server/domains/realtime/procedures.test.ts": 231, + "src/server/domains/tasks/taskNotificationQueue.test.ts": 229, + "src/server/trpc/trpc.test.ts": 225, + "src/server/test/contracts/superjsonTransport.test.ts": 222, + "src/server/database/migrations/migrationApplicationTime.test.ts": 220, + "src/server/domains/jobs/procedures.test.ts": 214, + "src/server/platform/realtime/eventPumpServiceFairness.test.ts": 213, + "scripts/runTestSuite.test.ts": 212, + "src/server/test/contracts/trpcErrors.test.ts": 208, + "src/server/platform/realtime/eventPumpServiceSubscription.test.ts": 204, + "src/contracts/cache.test.ts": 197, + "src/test/integration/resources/runSseMemoryEvidence.test.ts": 193, + "src/server/domains/jobs/workerRuntime.test.ts": 192, + "src/server/platform/observability/projectFileLogSink.test.ts": 190, + "scripts/delivery/productionActivationState.test.ts": 180, + "src/app/databaseMaintenance.test.ts": 179, + "src/server/domains/realtime/errors.test.ts": 179, + "src/server/trpc/procedureErrorPolicy.test.ts": 176, + "src/server/trpc/context.test.ts": 174, + "scripts/delivery/productionRuntime.test.ts": 164, + "src/server/database/validation/rowSchemaIntegration.test.ts": 160, + "scripts/documentation/artifacts.test.ts": 158, + "src/server/database/runtime/databasePath.test.ts": 158, + "src/worker/taskNotifications.test.ts": 155, + "src/server/database/validation/automationCredentials.test.ts": 151, + "src/server/platform/gateway/gatewayCredentialVerifier.test.ts": 151, + "src/server/platform/realtime/eventPumpServiceLifecycle.test.ts": 151, + "src/test/support/asyncCleanupStack.test.ts": 148, + "src/server/platform/configuration/webConfiguration.test.ts": 146, + "src/app/trpcRequestPolicy.test.ts": 145, + "src/server/domains/realtime/transport.test.ts": 145, + "scripts/delivery/productionStateFilesystem.test.ts": 143, + "src/server/database/validation/cacheEntries.test.ts": 141, + "src/test/integration/openclaw/sourceAudit.test.ts": 136, + "src/server/database/validation/userWebAuthnCredentials.test.ts": 135, + "src/server/database/validation/realtimeEvents.test.ts": 132, + "src/server/platform/errors/safeFailure.test.ts": 130, + "src/server/database/validation/users.test.ts": 128, + "src/server/domains/jobs/scheduleTime.test.ts": 128, + "src/contracts/jobs.test.ts": 126, + "src/test/parity/parityInventory.test.ts": 126, + "src/server/platform/release/runtimeRelease.test.ts": 123, + "src/server/domains/jobs/actionExecutors.test.ts": 122, + "src/server/rawHttp/frontendAssets.test.ts": 119, "src/server/domains/jobs/sideEffects.test.ts": 117, - "src/server/database/validation/authSessions.test.ts": 113, - "scripts/documentation/jsonSchema.test.ts": 110, - "src/contracts/contractRegistry.test.ts": 103, - "scripts/delivery/buildAdmission.test.ts": 100, - "src/server/domains/realtime/authenticationLeaseStream.test.ts": 99, - "src/contracts/accountSecurity.test.ts": 95, - "src/server/domains/jobs/actionRegistry.test.ts": 93, - "scripts/delivery/releaseArtifactInventory.test.ts": 90, - "src/server/database/validation/users.test.ts": 89, - "src/server/platform/release/runtimeRelease.test.ts": 86, - "src/server/database/validation/incidentObservations.test.ts": 85, - "scripts/runStorybookTests.test.ts": 83, - "src/server/database/validation/authRateLimitBuckets.test.ts": 80, - "src/server/database/validation/realtimeEvents.test.ts": 80, - "scripts/checkDatabaseSchema.test.ts": 79, - "src/server/database/validation/auditEvents.test.ts": 77, - "src/server/database/validation/automationCredentials.test.ts": 75, - "src/server/database/validation/userTotpFactors.test.ts": 73, - "src/contracts/schedules.test.ts": 71, - "src/server/platform/observability/projectFileLogSink.test.ts": 71, - "src/server/shared/crypto.test.ts": 71, - "src/server/database/validation/userRecoveryCodes.test.ts": 67, - "src/server/domains/security/audit.test.ts": 67, - "scripts/delivery/prepareProductionState.test.ts": 66, - "src/server/database/validation/authPendingLogins.test.ts": 66, - "src/contracts/jobs.test.ts": 65, - "src/server/database/validation/userWebAuthnCredentials.test.ts": 64, - "src/contracts/reports.test.ts": 63, - "src/server/rawHttp/authenticationClientSource.test.ts": 63, - "src/server/rawHttp/sessionCookie.test.ts": 63, - "src/server/database/schema/cacheEntries.test.ts": 62, - "src/server/domains/security/authenticationResolution.test.ts": 62, - "src/server/domains/security/mfa/webauthn/credentialState.test.ts": 61, - "src/contracts/agents.test.ts": 60, - "scripts/runCoverage.test.ts": 59, - "src/contracts/monitoring.test.ts": 57, - "scripts/checkCoverage.test.ts": 56, - "src/server/domains/security/mfa/totpSecretCipher.test.ts": 55, - "src/contracts/cacheRealtime.test.ts": 54, - "src/server/database/validation/automationPrincipalCapabilities.test.ts": 54, - "src/test/integration/resources/processMemory.test.ts": 54, - "src/server/platform/runtime/processSignals.test.ts": 53, - "src/contracts/tasks.test.ts": 52, - "src/server/database/validation/reports.test.ts": 51, - "src/server/domains/cache/systemHostProvider.test.ts": 51, - "src/test/integration/transport/realtime/eventFeed.test.ts": 51, - "src/contracts/securityAudit.test.ts": 50, - "src/server/platform/runtime/readRuntimeIdentity.test.ts": 49, - "src/server/domains/security/recentAuthentication.test.ts": 48, - "src/contracts/auth.test.ts": 47, - "src/server/platform/observability/structuredLogger.test.ts": 47, - "src/test/integration/resources/cgroupV2.test.ts": 47, - "src/server/platform/filesystem/projectLayout.test.ts": 46, - "src/test/integration/resources/sseMemoryEvidence.test.ts": 46, - "src/contracts/notifications.test.ts": 45, - "src/server/domains/security/mfa/totp.test.ts": 44, - "src/server/domains/tasks/taskNotification.test.ts": 44, - "src/server/shared/opaqueToken.test.ts": 44, - "src/test/integration/resources/pausedTlsSseHandshake.test.ts": 43, - "src/contracts/taskModel.test.ts": 42, - "src/shared/validation.test.ts": 42, + "src/test/integration/resourceBudgets/resourceBudgetOrchestration.test.ts": 116, + "src/server/database/validation/authPendingLogins.test.ts": 114, + "src/contracts/schedules.test.ts": 113, + "src/server/database/schema/webauthnPersistence.test.ts": 112, + "src/server/domains/security/audit.test.ts": 111, + "src/server/database/validation/incidents.test.ts": 110, + "src/server/domains/security/mfa/webauthn/adapter.test.ts": 109, + "scripts/delivery/buildAdmission.test.ts": 104, + "scripts/documentation/jsonSchema.test.ts": 102, + "src/contracts/events.test.ts": 100, + "src/test/integration/files/boundedFile.test.ts": 99, + "src/server/database/validation/authRateLimitBuckets.test.ts": 97, + "src/server/platform/observability/effectLogger.test.ts": 97, + "src/server/database/validation/auditEvents.test.ts": 96, + "src/server/database/schema/cacheEntries.test.ts": 94, + "src/server/database/validation/rowSchemas.test.ts": 93, + "src/server/database/validation/authChallenges.test.ts": 92, + "src/server/database/validation/authSessions.test.ts": 91, + "src/server/rawHttp/authenticationCredentials.test.ts": 91, + "src/contracts/jobModel.test.ts": 90, + "src/server/domains/realtime/authenticationLeaseStream.test.ts": 90, + "src/server/platform/realtime/eventPumpContract.test.ts": 89, + "src/contracts/agents.test.ts": 88, + "src/contracts/accountSecurity.test.ts": 84, + "src/contracts/agentModel.test.ts": 84, + "src/contracts/automationSecurity.test.ts": 83, + "src/server/database/validation/automationPrincipalCapabilities.test.ts": 83, + "src/server/database/validation/userTotpFactors.test.ts": 82, + "src/contracts/reports.test.ts": 80, + "src/server/database/validation/taskRows.test.ts": 80, + "src/test/integration/resources/systemdLauncher.test.ts": 80, + "src/server/domains/security/mfa/totpSecretCipher.test.ts": 79, + "src/server/database/validation/userRecoveryCodes.test.ts": 76, + "scripts/delivery/productionDeliveryFilesystem.test.ts": 74, + "src/contracts/contractRegistry.test.ts": 72, + "src/server/database/validation/incidentObservations.test.ts": 72, + "src/server/database/validation/reports.test.ts": 72, + "src/test/integration/resources/cgroupV2Hierarchy.test.ts": 71, + "src/server/domains/security/recentAuthentication.test.ts": 69, + "src/contracts/cacheRealtime.test.ts": 67, + "src/contracts/taskModel.test.ts": 67, + "src/server/domains/cache/systemHostProvider.test.ts": 67, + "src/server/database/validation/automationPrincipals.test.ts": 66, + "src/contracts/monitoring.test.ts": 65, + "src/server/domains/system/systemMetricsCollector.test.ts": 65, + "src/server/domains/security/mfa/webauthn/credentialState.test.ts": 64, + "scripts/runCoverage.test.ts": 63, + "src/server/domains/security/mfa/totp.test.ts": 63, + "src/shared/dateTime.test.ts": 62, + "src/server/database/validation/notifications.test.ts": 61, + "src/contracts/tasks.test.ts": 60, + "src/server/database/validation/monitorRuns.test.ts": 60, + "src/server/domains/jobs/actionRegistry.test.ts": 60, + "src/test/integration/transport/topology/proxyTransport.test.ts": 59, + "src/test/integration/resources/resourcePolicy.test.ts": 57, + "src/server/shared/crypto.test.ts": 56, + "src/server/shared/opaqueToken.test.ts": 56, + "src/test/integration/resources/cgroupV2.test.ts": 55, + "scripts/delivery/releaseArtifactInventory.test.ts": 54, + "src/server/platform/observability/structuredLogger.test.ts": 54, + "src/server/rawHttp/sessionCookie.test.ts": 54, + "src/test/integration/resources/sseMemoryEvidence.test.ts": 54, + "src/contracts/jobRealtime.test.ts": 53, + "src/shared/releaseManifest.test.ts": 51, + "src/test/integration/resources/processMemory.test.ts": 51, + "scripts/testOutputPolicy.test.ts": 50, + "src/server/rawHttp/authenticationClientSource.test.ts": 50, + "src/server/domains/security/authenticationResolution.test.ts": 49, + "src/shared/encoding.test.ts": 49, + "src/test/integration/resourceBudgets/resourceBudgetPolicy.test.ts": 49, + "src/server/rawHttp/pendingLoginCookie.test.ts": 47, + "src/server/platform/runtime/readRuntimeIdentity.test.ts": 46, + "src/test/integration/resources/unitIdentity.test.ts": 46, + "src/server/database/migrations/verifyDatabaseIntegrity.test.ts": 45, + "src/contracts/securityAudit.test.ts": 44, + "src/server/domains/tasks/taskNotification.test.ts": 43, + "scripts/checkCoverage.test.ts": 41, + "src/contracts/auth.test.ts": 41, + "src/contracts/security.test.ts": 41, "src/server/database/schema/automationPersistence.test.ts": 41, - "src/server/platform/configuration/workerConfiguration.test.ts": 41, - "src/shared/dateTime.test.ts": 41, - "src/contracts/agentModel.test.ts": 39, - "src/test/integration/resources/resourcePolicy.test.ts": 39, - "src/server/domains/security/authenticationSession.test.ts": 38, - "src/server/domains/security/mfa/recoveryCodes.test.ts": 38, - "src/test/integration/runtime/runtimeCandidate.test.ts": 38, - "scripts/delivery/systemctlProcess.test.ts": 36, - "src/server/domains/security/mfa/webauthn/relyingPartyConfiguration.test.ts": 36, - "src/server/platform/gateway/gatewayCredentialProtocol.test.ts": 36, - "scripts/packageIdentity.test.ts": 35, - "src/server/domains/security/authenticationPolicy.test.ts": 35, - "src/server/rawHttp/pendingLoginCookie.test.ts": 35, - "src/test/integration/resources/systemdLauncher.test.ts": 35, - "src/test/integration/transport/topology/proxyTransport.test.ts": 35, - "src/shared/json.test.ts": 33, - "src/server/database/migrations/verifyDatabaseIntegrity.test.ts": 32, - "src/server/platform/configuration/configurationRegistry.test.ts": 32, - "src/server/rawHttp/authenticationCredentials.test.ts": 31, - "src/shared/releaseManifest.test.ts": 31, - "src/test/integration/resourceBudgets/resourceBudgetPolicy.test.ts": 31, - "src/server/platform/realtime/boundedAsyncQueue.test.ts": 30, - "src/test/integration/resources/unitIdentity.test.ts": 29, - "scripts/documentation/configurationMarkdown.test.ts": 25, - "src/server/domains/security/authenticationWorkBudget.test.ts": 24, - "src/contracts/webauthn.test.ts": 23, - "src/shared/encoding.test.ts": 23, - "scripts/testOutputPolicy.test.ts": 22, - "src/server/rawHttp/requestSecurity.test.ts": 21, - "src/test/integration/resources/cgroupV2Hierarchy.test.ts": 21, - "src/test/setup.test.ts": 21, - "src/contracts/security.test.ts": 18 + "src/test/integration/transport/realtime/eventFeed.test.ts": 41, + "src/shared/json.test.ts": 39, + "src/shared/validation.test.ts": 39, + "src/test/setup.test.ts": 39, + "src/server/rawHttp/requestSecurity.test.ts": 38, + "src/test/integration/resources/pausedTlsSseHandshake.test.ts": 37, + "src/test/integration/runtime/runtimeCandidate.test.ts": 37, + "src/server/domains/security/authenticationSession.test.ts": 36, + "scripts/delivery/systemctlProcess.test.ts": 34, + "scripts/runStorybookTests.test.ts": 34, + "src/server/platform/runtime/processSignals.test.ts": 34, + "src/contracts/notifications.test.ts": 33, + "src/server/domains/security/mfa/recoveryCodes.test.ts": 33, + "src/server/platform/configuration/workerConfiguration.test.ts": 32, + "src/server/platform/filesystem/projectLayout.test.ts": 31, + "scripts/documentation/configurationMarkdown.test.ts": 30, + "src/server/domains/security/authenticationPolicy.test.ts": 29, + "src/server/domains/system/systemMetricsService.test.ts": 29, + "src/server/platform/configuration/configurationRegistry.test.ts": 29, + "scripts/delivery/prepareProductionState.test.ts": 27, + "scripts/packageIdentity.test.ts": 27, + "src/contracts/system.test.ts": 26, + "src/server/domains/security/authenticationWorkBudget.test.ts": 25, + "src/server/domains/security/mfa/webauthn/relyingPartyConfiguration.test.ts": 25, + "src/server/platform/gateway/gatewayCredentialProtocol.test.ts": 25, + "src/server/platform/realtime/boundedAsyncQueue.test.ts": 22, + "scripts/checkDatabaseSchema.test.ts": 20, + "src/contracts/webauthn.test.ts": 14 }, "version": 1 } diff --git a/greenfield/docs/architecture/greenfield-rewrite/progress.md b/greenfield/docs/architecture/greenfield-rewrite/progress.md index 50ad4eef9..37b092125 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/progress.md +++ b/greenfield/docs/architecture/greenfield-rewrite/progress.md @@ -1019,3 +1019,27 @@ full-browser parity, production rehearsal, cutover, and legacy deletion remain o in this slice. Root-route parity remains `planned`: metrics and the remaining Phase 3 overview composition are still open. Parity bookkeeping assigns OpenClaw cron and `cache.getHeartbeat` to Phase 4 with their persistent authenticated Gateway dependency. + +### 2026-08-09 — Phase 3 system metrics and overview gauges + +- Browser sessions now have one `system.metrics` query with no automation capability. Its strict + response exposes only CPU load, memory and root-disk capacity, aggregate network throughput, + uptime, sample time, and freshness. Hostname, CPU model, interface identity, raw collector + failures, control authority, shell execution, and a new realtime topic remain outside the + contract. +- The process runtime owns one demand-driven sampler. Concurrent reads share a single in-flight + collection, successful reads retain one last-known-good snapshot, and a failed collection may + return that snapshot as explicitly stale for at most 30 seconds before the procedure fails with + fixed `SERVICE_UNAVAILABLE` text. Linux network rates warm from two monotonic aggregate counter + samples; counter resets and clock regressions return to the disclosed warming state. +- The root route polls the ordinary TanStack Query every five seconds and renders accessible CPU, + memory, disk, uptime, download, and upload cards above the cache browser. Query failures retain + validated data, server fallback is visibly marked stale, the first network sample says + `Sampling…`, and no chart dependency or duplicate client state owner is introduced. +- Contract, collector, single-flight, authorization, safe-error, polling, formatting, component, + Storybook, and route tests cover the slice. `system.metrics` is implemented and registered, while + legacy `GET /api/metrics` parity remains `planned` because its application-observability, HTTP, + polling-snapshot, and token projections are not part of this bounded host-gauge procedure. Full + `/` parity also remains `planned`: the remaining Phase 3 overview composition and later-phase + weather, quota, Git, Docker, database, backup, and privileged operational cards are not claimed + by this metrics slice. diff --git a/greenfield/docs/generated/procedures.md b/greenfield/docs/generated/procedures.md index 21fe01d62..573a9479c 100644 --- a/greenfield/docs/generated/procedures.md +++ b/greenfield/docs/generated/procedures.md @@ -72,6 +72,7 @@ | `schedules.run` | mutation | schedules | Authenticated: jobs:write | [input](./schemas/schedules.run.input.schema.json) | [output](./schemas/schedules.run.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Enqueues one caller-scoped idempotent manual schedule run. | | `schedules.update` | mutation | schedules | Authenticated browser session: jobs:write | [input](./schemas/schedules.update.input.schema.json) | [output](./schemas/schedules.update.output.schema.json) | `BAD_REQUEST`, `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Updates one schedule or its explicit disable intent by version. | | `securityAudit.listEvents` | query | securityAudit | Authenticated browser session | [input](./schemas/securityAudit.listEvents.input.schema.json) | [output](./schemas/securityAudit.listEvents.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | None | Lists redacted immutable security events in stable newest-first order. | +| `system.metrics` | query | system | Authenticated browser session | [input](./schemas/system.metrics.input.schema.json) | [output](./schemas/system.metrics.output.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Returns bounded host gauges and throughput without host identity or control authority. | | `system.runtimeIdentity` | query | system | Public | [input](./schemas/system.runtimeIdentity.input.schema.json) | [output](./schemas/system.runtimeIdentity.output.schema.json) | None | None | Returns the Bun runtime identity of the serving process. | | `tasks.addUpdate` | mutation | tasks | Authenticated: tasks:write | [input](./schemas/tasks.addUpdate.input.schema.json) | [output](./schemas/tasks.addUpdate.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Appends one authenticated progress update to a task. | | `tasks.assign` | mutation | tasks | Authenticated: tasks:write | [input](./schemas/tasks.assign.input.schema.json) | [output](./schemas/tasks.assign.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Assigns or unassigns a task under optimistic concurrency control. | diff --git a/greenfield/docs/generated/schemas/system.metrics.input.schema.json b/greenfield/docs/generated/schemas/system.metrics.input.schema.json new file mode 100644 index 000000000..ddda72247 --- /dev/null +++ b/greenfield/docs/generated/schemas/system.metrics.input.schema.json @@ -0,0 +1,9 @@ +{ + "$id": "urn:mira-dashboard:system.metrics.input", + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false, + "default": {}, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/system.metrics.output.schema.json b/greenfield/docs/generated/schemas/system.metrics.output.schema.json new file mode 100644 index 000000000..db15f4328 --- /dev/null +++ b/greenfield/docs/generated/schemas/system.metrics.output.schema.json @@ -0,0 +1,170 @@ +{ + "$id": "urn:mira-dashboard:system.metrics.output", + "type": "object", + "properties": { + "cpu": { + "type": "object", + "properties": { + "loadAverage": { + "type": "array", + "prefixItems": [ + { + "type": "number", + "minimum": 0, + "maximum": 100000 + }, + { + "type": "number", + "minimum": 0, + "maximum": 100000 + }, + { + "type": "number", + "minimum": 0, + "maximum": 100000 + } + ], + "minItems": 3 + }, + "loadPercent": { + "type": "number", + "minimum": 0, + "maximum": 10000 + }, + "logicalCoreCount": { + "type": "integer", + "minimum": 1, + "maximum": 8192 + } + }, + "required": [ + "loadAverage", + "loadPercent", + "logicalCoreCount" + ], + "additionalProperties": false + }, + "disk": { + "type": "object", + "properties": { + "freeBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "totalBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "usedBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "usedPercent": { + "type": "number", + "minimum": 0, + "maximum": 100 + } + }, + "required": [ + "freeBytes", + "totalBytes", + "usedBytes", + "usedPercent" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires capacity bytes and the rounded percentage to describe one consistent state." + }, + "freshness": { + "enum": [ + "fresh", + "stale" + ], + "type": "string" + }, + "memory": { + "type": "object", + "properties": { + "freeBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "totalBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "usedBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "usedPercent": { + "type": "number", + "minimum": 0, + "maximum": 100 + } + }, + "required": [ + "freeBytes", + "totalBytes", + "usedBytes", + "usedPercent" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires capacity bytes and the rounded percentage to describe one consistent state." + }, + "network": { + "type": "object", + "properties": { + "downloadBitsPerSecond": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "state": { + "enum": [ + "ready", + "warming" + ], + "type": "string" + }, + "uploadBitsPerSecond": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "downloadBitsPerSecond", + "state", + "uploadBitsPerSecond" + ], + "additionalProperties": false + }, + "sampledAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "uptimeSeconds": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "cpu", + "disk", + "freshness", + "memory", + "network", + "sampledAtMs", + "uptimeSeconds" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/scripts/documentation/jsonSchema.ts b/greenfield/scripts/documentation/jsonSchema.ts index 57b80cf4f..db6acb8f2 100644 --- a/greenfield/scripts/documentation/jsonSchema.ts +++ b/greenfield/scripts/documentation/jsonSchema.ts @@ -115,6 +115,7 @@ import { securityAuditEventsHaveStableOrder, securityAuditPageCursorIsConsistent, } from "../../src/contracts/securityAudit.ts"; +import { systemMetricCapacityIsConsistent } from "../../src/contracts/system.ts"; import { canonicalizeTaskStrings, freezeTaskStrings, @@ -164,6 +165,10 @@ const controlSafeTextJsonSchemaPattern = `^(?![\\s\\S]*(?:${securityLabelControl const noNulJsonSchemaPattern = String.raw`^[^\u0000]*$`; const runtimeCheckComments = new Map([ + [ + systemMetricCapacityIsConsistent, + "Live Valibot validation additionally requires capacity bytes and the rounded percentage to describe one consistent state.", + ], [ cacheEntryPayloadFitsBudget, "Live Valibot validation additionally limits the serialized cache payload to its reviewed UTF-8 byte budget.", diff --git a/greenfield/src/browser/cache/cachePresentation.ts b/greenfield/src/browser/cache/cachePresentation.ts index eff38aed2..fc4a0bc41 100644 --- a/greenfield/src/browser/cache/cachePresentation.ts +++ b/greenfield/src/browser/cache/cachePresentation.ts @@ -3,8 +3,10 @@ import { classifyDashboardBrowserFailure, type DashboardBrowserFailure, } from "../api/trpcError.ts"; - -const byteUnits = ["B", "KiB", "MiB", "GiB", "TiB"] as const; +export { + formatByteCount as formatCacheBytes, + formatUptime as formatCacheUptime, +} from "../lib/formatMeasurements.ts"; /** * @param freshness Independently derived cache freshness. @@ -28,22 +30,6 @@ export function cacheAttemptVariant( return status === "succeeded" ? "success" : "danger"; } -/** - * Formats a nonnegative byte count without exposing locale- or host-specific state. - * @param bytes Validated cache byte count. - * @returns Compact binary-capacity label. - */ -export function formatCacheBytes(bytes: number): string { - let value = bytes; - let unitIndex = 0; - while (value >= 1024 && unitIndex < byteUnits.length - 1) { - value /= 1024; - unitIndex += 1; - } - const digits = value >= 10 || unitIndex === 0 ? 0 : 1; - return `${value.toFixed(digits)} ${byteUnits[unitIndex]}`; -} - /** * Formats a nonnegative duration for concise operator metadata. * @param milliseconds Validated duration in milliseconds. @@ -55,20 +41,6 @@ export function formatCacheDuration(milliseconds: number): string { return `${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)} s`; } -/** - * Formats validated host uptime without relying on wall-clock state. - * @param seconds Whole uptime seconds. - * @returns Human-readable bounded uptime. - */ -export function formatCacheUptime(seconds: number): string { - const days = Math.floor(seconds / 86_400); - const hours = Math.floor((seconds % 86_400) / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - if (days > 0) return `${days}d ${hours}h`; - if (hours > 0) return `${hours}h ${minutes}m`; - return `${minutes}m`; -} - const cacheFailureMessages: Readonly> = { cancelled: "The cache request was cancelled. You can try again.", conflict: diff --git a/greenfield/src/browser/lib/formatMeasurements.test.ts b/greenfield/src/browser/lib/formatMeasurements.test.ts new file mode 100644 index 000000000..2520dc41d --- /dev/null +++ b/greenfield/src/browser/lib/formatMeasurements.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; + +import { + formatBitsPerSecond, + formatByteCount, + formatLoadValue, + formatPercent, + formatUptime, +} from "./formatMeasurements.ts"; + +describe("measurement formatting", () => { + test("formats validated capacity, load and uptime values compactly", () => { + expect(formatByteCount(0)).toBe("0 B"); + expect(formatByteCount(1536)).toBe("1.5 KiB"); + expect(formatPercent(75)).toBe("75%"); + expect(formatPercent(248.1)).toBe("248.1%"); + expect(formatLoadValue(9.9)).toBe("9.9"); + expect(formatUptime(183_600)).toBe("2d 3h"); + }); + + test("uses decimal network units without noisy trailing zeros", () => { + expect(formatBitsPerSecond(0)).toBe("0 bit/s"); + expect(formatBitsPerSecond(800)).toBe("800 bit/s"); + expect(formatBitsPerSecond(12_300_000)).toBe("12.3 Mbit/s"); + expect(formatBitsPerSecond(1_250_000_000)).toBe("1.25 Gbit/s"); + }); +}); diff --git a/greenfield/src/browser/lib/formatMeasurements.ts b/greenfield/src/browser/lib/formatMeasurements.ts new file mode 100644 index 000000000..78c7f2801 --- /dev/null +++ b/greenfield/src/browser/lib/formatMeasurements.ts @@ -0,0 +1,70 @@ +const binaryByteUnits = ["B", "KiB", "MiB", "GiB", "TiB"] as const; +const bitRateUnits = ["bit/s", "kbit/s", "Mbit/s", "Gbit/s", "Tbit/s"] as const; + +function compactDecimal(value: number, maximumDigits: number): string { + return value + .toFixed(maximumDigits) + .replace(/\.0+$/u, "") + .replace(/(?\.\d*?)0+$/u, "$"); +} + +/** + * @param bytes - Validated nonnegative byte count. + * @returns Compact binary byte capacity. + */ +export function formatByteCount(bytes: number): string { + let value = bytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < binaryByteUnits.length - 1) { + value /= 1024; + unitIndex += 1; + } + const digits = value >= 10 || unitIndex === 0 ? 0 : 1; + return `${value.toFixed(digits)} ${binaryByteUnits[unitIndex]}`; +} + +/** + * @param bitsPerSecond - Validated nonnegative bit rate. + * @returns Compact decimal bit rate. + */ +export function formatBitsPerSecond(bitsPerSecond: number): string { + let value = bitsPerSecond; + let unitIndex = 0; + while (value >= 1000 && unitIndex < bitRateUnits.length - 1) { + value /= 1000; + unitIndex += 1; + } + let digits = 2; + if (value >= 100) digits = 0; + else if (value >= 10) digits = 1; + return `${compactDecimal(value, digits)} ${bitRateUnits[unitIndex]}`; +} + +/** + * @param percent - Validated percentage. + * @returns Stable compact percentage retaining at most one decimal. + */ +export function formatPercent(percent: number): string { + return `${compactDecimal(percent, 1)}%`; +} + +/** + * @param load - Validated load value. + * @returns Stable compact load value retaining at most two decimals. + */ +export function formatLoadValue(load: number): string { + return compactDecimal(load, 2); +} + +/** + * @param seconds - Validated nonnegative uptime in seconds. + * @returns Human-readable bounded uptime without wall-clock dependence. + */ +export function formatUptime(seconds: number): string { + const days = Math.floor(seconds / 86_400); + const hours = Math.floor((seconds % 86_400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} diff --git a/greenfield/src/browser/overview/OverviewRoute.test.tsx b/greenfield/src/browser/overview/OverviewRoute.test.tsx index 5ccadb970..1ed004c04 100644 --- a/greenfield/src/browser/overview/OverviewRoute.test.tsx +++ b/greenfield/src/browser/overview/OverviewRoute.test.tsx @@ -10,6 +10,7 @@ import type { RefreshCacheEntryInput, } from "../../contracts/cache.ts"; import type { JobRunSummary } from "../../contracts/jobModel.ts"; +import type { SystemMetrics } from "../../contracts/system.ts"; import { createDashboardQueryClient } from "../api/queryClient.ts"; import { createDashboardTrpcClient, @@ -25,7 +26,7 @@ import type { DashboardWebAuthnClient } from "../security/webauthn/webauthnClien import { emptyNotificationListResult } from "../test/notifications.ts"; import { noOpDashboardRealtimeClient } from "../test/realtime.ts"; -const { render, screen, waitFor } = await import("@testing-library/react"); +const { render, screen, waitFor, within } = await import("@testing-library/react"); const userEventModule = await import("@testing-library/user-event"); const userEvent = userEventModule.default; @@ -51,6 +52,34 @@ const authenticatedStatus: AuthStatus = { }, }; +const systemMetrics = Object.freeze({ + cpu: { + loadAverage: [2, 1, 0.5], + loadPercent: 50, + logicalCoreCount: 4, + }, + disk: { + freeBytes: 40 * 1024 ** 3, + totalBytes: 100 * 1024 ** 3, + usedBytes: 60 * 1024 ** 3, + usedPercent: 60, + }, + freshness: "fresh", + memory: { + freeBytes: 2 * 1024 ** 3, + totalBytes: 8 * 1024 ** 3, + usedBytes: 6 * 1024 ** 3, + usedPercent: 75, + }, + network: { + downloadBitsPerSecond: 12_300_000, + state: "ready", + uploadBitsPerSecond: 1_250_000, + }, + sampledAtMs: timestampMs, + uptimeSeconds: 183_600, +} as const satisfies SystemMetrics); + const hostEntry = Object.freeze({ consecutiveFailures: 1, expiresAtMs: timestampMs + 60_000, @@ -164,6 +193,7 @@ interface OverviewTransportOptions { readonly cacheEntryOutputs?: readonly (CacheEntry | Error)[]; readonly cacheStatusOutputs?: readonly (CacheStatusResult | Error)[]; readonly refreshOutputs?: readonly (JobRunSummary | Error)[]; + readonly systemMetricsOutputs?: readonly (SystemMetrics | Error)[]; } function transportOutput( @@ -182,6 +212,7 @@ class OverviewTransport implements DashboardTrpcTransport { readonly #cacheEntryOutputs: readonly (CacheEntry | Error)[]; readonly #cacheStatusOutputs: readonly (CacheStatusResult | Error)[]; readonly #refreshOutputs: readonly (JobRunSummary | Error)[]; + readonly #systemMetricsOutputs: readonly (SystemMetrics | Error)[]; readonly mutationCalls: TransportCall[] = []; readonly queryCalls: TransportCall[] = []; @@ -189,6 +220,7 @@ class OverviewTransport implements DashboardTrpcTransport { this.#cacheEntryOutputs = options.cacheEntryOutputs ?? [hostEntry]; this.#cacheStatusOutputs = options.cacheStatusOutputs ?? [cacheStatus]; this.#refreshOutputs = options.refreshOutputs ?? [queuedRefresh]; + this.#systemMetricsOutputs = options.systemMetricsOutputs ?? [systemMetrics]; } mutation(path: string, input?: unknown): Promise { @@ -219,6 +251,9 @@ class OverviewTransport implements DashboardTrpcTransport { case "notifications.list": { return Promise.resolve(emptyNotificationListResult); } + case "system.metrics": { + return transportOutput(this.#systemMetricsOutputs, callIndex, path); + } default: { return Promise.reject(new TypeError(`Unexpected query: ${path}`)); } @@ -261,7 +296,7 @@ function renderOverview(transport: OverviewTransport) { return { queryClient, user: userEvent.setup() }; } -describe("Dashboard overview cache foundation", () => { +describe("Dashboard operational overview foundation", () => { test("loads bounded status before exact payload and queues refresh as a job", async () => { const transport = new OverviewTransport(); const { user } = renderOverview(transport); @@ -269,6 +304,15 @@ describe("Dashboard overview cache foundation", () => { expect( await screen.findByRole("heading", { level: 1, name: "Mira Dashboard" }) ).toBeTruthy(); + expect( + await screen.findByRole("heading", { level: 2, name: "System metrics" }) + ).toBeTruthy(); + const cpuHeading = screen.getByRole("heading", { level: 3, name: "CPU" }); + const cpuCard = cpuHeading.closest("section"); + expect(cpuCard).toBeTruthy(); + expect(within(cpuCard as HTMLElement).getByText("50%")).toBeTruthy(); + expect(screen.getByText("12.3 Mbit/s")).toBeTruthy(); + expect(screen.queryByText("mira-vps")).toBeNull(); expect(await screen.findByText("Showing 2 of 129")).toBeTruthy(); expect( transport.queryCalls.filter(({ path }) => path === "cache.getEntry") @@ -310,6 +354,21 @@ describe("Dashboard overview cache foundation", () => { expect(queuedRunLink.getAttribute("href")).toContain(refreshRunId); }); + test("marks a recent last-known-good metric sample without hiding cache data", async () => { + const transport = new OverviewTransport({ + systemMetricsOutputs: [{ ...systemMetrics, freshness: "stale" }], + }); + renderOverview(transport); + + expect( + await screen.findByText( + "The latest collection failed. Showing a last-known-good sample no more than 30 seconds old." + ) + ).toBeTruthy(); + expect(screen.getByText("stale")).toBeTruthy(); + expect(await screen.findByText("Showing 2 of 129")).toBeTruthy(); + }); + test("does not present an empty truncated snapshot as a complete inventory", async () => { const transport = new OverviewTransport({ cacheStatusOutputs: [ diff --git a/greenfield/src/browser/overview/OverviewRoute.tsx b/greenfield/src/browser/overview/OverviewRoute.tsx index e2c13ea4e..489f91c5f 100644 --- a/greenfield/src/browser/overview/OverviewRoute.tsx +++ b/greenfield/src/browser/overview/OverviewRoute.tsx @@ -1,5 +1,6 @@ import { CacheBrowser } from "../cache/CacheBrowser.tsx"; import { PageHeader } from "../ui/PageHeader.tsx"; +import { SystemMetricsSection } from "./SystemMetricsSection.tsx"; /** @returns Progressive operational overview composed only from implemented domains. */ export function OverviewRoute() { @@ -11,6 +12,9 @@ export function OverviewRoute() { title="Mira Dashboard" />
+ +
+
diff --git a/greenfield/src/browser/overview/SystemMetricsCards.test.tsx b/greenfield/src/browser/overview/SystemMetricsCards.test.tsx new file mode 100644 index 000000000..a482cf4b2 --- /dev/null +++ b/greenfield/src/browser/overview/SystemMetricsCards.test.tsx @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; + +import type { SystemMetrics } from "../../contracts/system.ts"; +import { SystemMetricsCards } from "./SystemMetricsCards.tsx"; + +const { render, screen, within } = await import("@testing-library/react"); + +const metrics = Object.freeze({ + cpu: { + loadAverage: [9.92, 4.2, 2.1], + loadPercent: 248, + logicalCoreCount: 4, + }, + disk: { + freeBytes: 40 * 1024 ** 3, + totalBytes: 100 * 1024 ** 3, + usedBytes: 60 * 1024 ** 3, + usedPercent: 60, + }, + freshness: "fresh", + memory: { + freeBytes: 2 * 1024 ** 3, + totalBytes: 8 * 1024 ** 3, + usedBytes: 6 * 1024 ** 3, + usedPercent: 75, + }, + network: { + downloadBitsPerSecond: 12_300_000, + state: "ready", + uploadBitsPerSecond: 1_250_000, + }, + sampledAtMs: 1_800_000_000_000, + uptimeSeconds: 183_600, +} as const satisfies SystemMetrics); + +function card(title: string): HTMLElement { + const heading = screen.getByRole("heading", { level: 3, name: title }); + const element = heading.closest("section"); + if (!(element instanceof HTMLElement)) throw new TypeError("Missing metric card"); + return element; +} + +describe("SystemMetricsCards", () => { + test("renders reviewed values without host or interface identity", () => { + render(); + + expect(within(card("CPU")).getByText("248%")).toBeTruthy(); + expect(within(card("CPU")).getByText(/1 minute load 9\.92/u)).toBeTruthy(); + expect(within(card("Memory")).getByText("75%")).toBeTruthy(); + expect(within(card("Disk")).getByText("60%")).toBeTruthy(); + expect(within(card("Uptime")).getByText("2d 3h")).toBeTruthy(); + expect(within(card("Download")).getByText("12.3 Mbit/s")).toBeTruthy(); + expect(screen.queryByText(/hostname|interface|model/iu)).toBeNull(); + }); + + test("discloses the first network sample as warming", () => { + render( + + ); + + expect(screen.getAllByText("Sampling…")).toHaveLength(2); + expect(screen.queryByText("0 bit/s")).toBeNull(); + }); +}); diff --git a/greenfield/src/browser/overview/SystemMetricsCards.tsx b/greenfield/src/browser/overview/SystemMetricsCards.tsx new file mode 100644 index 000000000..91bf68cc1 --- /dev/null +++ b/greenfield/src/browser/overview/SystemMetricsCards.tsx @@ -0,0 +1,95 @@ +import { ArrowDown, ArrowUp, Clock, Cpu, HardDrive, MemoryStick } from "lucide-react"; + +import type { SystemMetrics } from "../../contracts/system.ts"; +import { + formatBitsPerSecond, + formatByteCount, + formatLoadValue, + formatPercent, + formatUptime, +} from "../lib/formatMeasurements.ts"; +import { MetricCard } from "../ui/MetricCard.tsx"; + +function capacityDescription(capacity: SystemMetrics["memory"]): string { + return `${formatByteCount(capacity.usedBytes)} / ${formatByteCount( + capacity.totalBytes + )} · ${formatByteCount(capacity.freeBytes)} free`; +} + +function networkValue( + state: SystemMetrics["network"]["state"], + bitsPerSecond: number +): string { + return state === "warming" ? "Sampling…" : formatBitsPerSecond(bitsPerSecond); +} + +interface SystemMetricsCardsProps { + readonly metrics: SystemMetrics; +} + +/** @returns Six identity-free operational metric cards. */ +export function SystemMetricsCards({ metrics }: SystemMetricsCardsProps) { + const coreLabel = + metrics.cpu.logicalCoreCount === 1 ? "logical core" : "logical cores"; + return ( +
+ + + + + + +
+ ); +} diff --git a/greenfield/src/browser/overview/SystemMetricsSection.tsx b/greenfield/src/browser/overview/SystemMetricsSection.tsx new file mode 100644 index 000000000..d604518e0 --- /dev/null +++ b/greenfield/src/browser/overview/SystemMetricsSection.tsx @@ -0,0 +1,86 @@ +import { useQuery } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import { formatDashboardDateTime } from "../lib/formatDateTime.ts"; +import { Alert } from "../ui/Alert.tsx"; +import { Badge } from "../ui/Badge.tsx"; +import { Card } from "../ui/Card.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { PageState } from "../ui/PageState.tsx"; +import { Text } from "../ui/Text.tsx"; +import { SystemMetricsCards } from "./SystemMetricsCards.tsx"; +import { systemMetricsFailureMessage } from "./systemMetricsPresentation.ts"; +import { systemMetricsQueryOptions } from "./systemMetricsQueries.ts"; + +/** @returns Five-second read-only metrics overview retaining validated query data. */ +export function SystemMetricsSection() { + const client = useDashboardTrpcClient(); + const query = useQuery(systemMetricsQueryOptions(client)); + let content: ReactNode; + + if (query.isPending && query.data === undefined) { + content = ( + + + + ); + } else if (query.data === undefined) { + content = ( + void query.refetch()} + retryBusy={query.isFetching} + status="error" + title="System metrics unavailable" + /> + ); + } else { + content = ; + } + + return ( +
+
+
+ + System metrics + + + Demand-driven host gauges without identity or control authority. + +
+ {query.data !== undefined && ( +
+ + {query.data.freshness} + + + Sampled {formatDashboardDateTime(query.data.sampledAtMs)} + +
+ )} +
+ {query.error !== null && query.data !== undefined && ( + + )} + {query.data?.freshness === "stale" && query.error === null && ( + + )} +
{content}
+
+ ); +} diff --git a/greenfield/src/browser/overview/stories/SystemMetricsCards.stories.tsx b/greenfield/src/browser/overview/stories/SystemMetricsCards.stories.tsx new file mode 100644 index 000000000..1ee437dab --- /dev/null +++ b/greenfield/src/browser/overview/stories/SystemMetricsCards.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from "@storybook/tanstack-react"; +import { expect, within } from "storybook/test"; + +import type { SystemMetrics } from "../../../contracts/system.ts"; +import { SystemMetricsCards } from "../SystemMetricsCards.tsx"; + +const freshMetrics = Object.freeze({ + cpu: { + loadAverage: [9.92, 4.2, 2.1], + loadPercent: 248, + logicalCoreCount: 4, + }, + disk: { + freeBytes: 40 * 1024 ** 3, + totalBytes: 100 * 1024 ** 3, + usedBytes: 60 * 1024 ** 3, + usedPercent: 60, + }, + freshness: "fresh", + memory: { + freeBytes: 2 * 1024 ** 3, + totalBytes: 8 * 1024 ** 3, + usedBytes: 6 * 1024 ** 3, + usedPercent: 75, + }, + network: { + downloadBitsPerSecond: 12_300_000, + state: "ready", + uploadBitsPerSecond: 1_250_000, + }, + sampledAtMs: 1_800_000_000_000, + uptimeSeconds: 183_600, +} as const satisfies SystemMetrics); + +const meta = { + args: { metrics: freshMetrics }, + component: SystemMetricsCards, + parameters: { layout: "padded" }, + title: "Overview/SystemMetricsCards", +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Fresh: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole("heading", { name: "CPU" })).toBeVisible(); + await expect(canvas.getByText("12.3 Mbit/s")).toBeVisible(); + }, +}; + +export const WarmingNetwork: Story = { + args: { + metrics: { + ...freshMetrics, + network: { + downloadBitsPerSecond: 0, + state: "warming", + uploadBitsPerSecond: 0, + }, + }, + }, +}; diff --git a/greenfield/src/browser/overview/systemMetricsPresentation.test.ts b/greenfield/src/browser/overview/systemMetricsPresentation.test.ts new file mode 100644 index 000000000..5c9b0d337 --- /dev/null +++ b/greenfield/src/browser/overview/systemMetricsPresentation.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; + +import { DashboardProtocolError } from "../api/trpcClient.ts"; +import { systemMetricsFailureMessage } from "./systemMetricsPresentation.ts"; + +describe("system metrics presentation", () => { + test("maps failures to fixed text without raw transport details", () => { + const secret = "private /proc failure"; + expect(systemMetricsFailureMessage(new TypeError(secret))).toBe( + "The system metrics request could not be completed. Try again." + ); + expect(systemMetricsFailureMessage(new DashboardProtocolError())).toBe( + "The server returned an invalid system metrics response. Reload before retrying." + ); + expect( + systemMetricsFailureMessage({ data: { code: "SERVICE_UNAVAILABLE" } }) + ).toBe("System metrics are temporarily unavailable. Try again shortly."); + expect(systemMetricsFailureMessage(new TypeError(secret))).not.toContain(secret); + }); +}); diff --git a/greenfield/src/browser/overview/systemMetricsPresentation.ts b/greenfield/src/browser/overview/systemMetricsPresentation.ts new file mode 100644 index 000000000..fd5978322 --- /dev/null +++ b/greenfield/src/browser/overview/systemMetricsPresentation.ts @@ -0,0 +1,29 @@ +import { + classifyDashboardBrowserFailure, + type DashboardBrowserFailure, +} from "../api/trpcError.ts"; + +const systemMetricsFailureMessages: Readonly> = { + cancelled: "The system metrics request was cancelled. You can try again.", + conflict: "System metric state changed. Try the request again.", + forbidden: "This browser session is not permitted to read system metrics.", + "invalid-request": "The system metrics request was rejected. Reload the page.", + "mfa-enrollment-required": + "Multi-factor authentication must be enrolled before this request.", + "not-found": "The system metrics procedure is not available in this release.", + protocol: + "The server returned an invalid system metrics response. Reload before retrying.", + "rate-limited": "Too many system metrics requests were made. Wait before retrying.", + "step-up-required": "Verify your identity again before reading system metrics.", + unauthorized: "The credentials or browser session are no longer valid.", + unavailable: "System metrics are temporarily unavailable. Try again shortly.", + unknown: "The system metrics request could not be completed. Try again.", +}; + +/** + * @param error - Unknown browser transport failure. + * @returns Fixed metrics-specific text without untrusted transport details. + */ +export function systemMetricsFailureMessage(error: unknown): string { + return systemMetricsFailureMessages[classifyDashboardBrowserFailure(error)]; +} diff --git a/greenfield/src/browser/overview/systemMetricsQueries.test.ts b/greenfield/src/browser/overview/systemMetricsQueries.test.ts new file mode 100644 index 000000000..ec177d449 --- /dev/null +++ b/greenfield/src/browser/overview/systemMetricsQueries.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, jest, test } from "bun:test"; + +import { QueryObserver } from "@tanstack/react-query"; +import type { TRPCRequestOptions } from "@trpc/client"; + +import type { SystemMetrics } from "../../contracts/system.ts"; +import { createDashboardQueryClient } from "../api/queryClient.ts"; +import { + createDashboardTrpcClient, + type DashboardTrpcTransport, +} from "../api/trpcClient.ts"; +import { + systemMetricsQueryKey, + systemMetricsQueryOptions, + systemMetricsRefreshIntervalMs, +} from "./systemMetricsQueries.ts"; + +const freshMetrics = Object.freeze({ + cpu: { + loadAverage: [2, 1, 0.5], + loadPercent: 50, + logicalCoreCount: 4, + }, + disk: { + freeBytes: 400, + totalBytes: 1000, + usedBytes: 600, + usedPercent: 60, + }, + freshness: "fresh", + memory: { + freeBytes: 250, + totalBytes: 1000, + usedBytes: 750, + usedPercent: 75, + }, + network: { + downloadBitsPerSecond: 800, + state: "ready", + uploadBitsPerSecond: 400, + }, + sampledAtMs: 1_800_000_000_000, + uptimeSeconds: 12, +} as const satisfies SystemMetrics); + +interface QueryCall { + readonly input: unknown; + readonly path: string; + readonly signal: AbortSignal | undefined; +} + +class SystemMetricsTransport implements DashboardTrpcTransport { + readonly calls: QueryCall[] = []; + readonly #outputs: readonly SystemMetrics[]; + + constructor(outputs: readonly SystemMetrics[]) { + this.#outputs = outputs; + } + + mutation(path: string): Promise { + return Promise.reject(new TypeError(`Unexpected mutation: ${path}`)); + } + + query(path: string, input?: unknown, options?: TRPCRequestOptions): Promise { + const output = + this.#outputs[Math.min(this.calls.length, this.#outputs.length - 1)]; + this.calls.push({ input, path, signal: options?.signal }); + return output === undefined + ? Promise.reject(new TypeError("Missing metrics output")) + : Promise.resolve(output); + } +} + +class HangingSystemMetricsTransport implements DashboardTrpcTransport { + readonly started = Promise.withResolvers(); + + mutation(path: string): Promise { + return Promise.reject(new TypeError(`Unexpected mutation: ${path}`)); + } + + query( + _path: string, + _input?: unknown, + options?: TRPCRequestOptions + ): Promise { + const signal = options?.signal; + if (signal === undefined) { + return Promise.reject(new TypeError("Missing query cancellation signal")); + } + this.started.resolve(signal); + return new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => reject(new DOMException("Aborted", "AbortError")), + { once: true } + ); + }); + } +} + +describe("system metrics query", () => { + test("polls every five seconds and retains explicit server freshness", async () => { + jest.useFakeTimers(); + const staleMetrics = { + ...freshMetrics, + freshness: "stale" as const, + } satisfies SystemMetrics; + const transport = new SystemMetricsTransport([freshMetrics, staleMetrics]); + const queryClient = createDashboardQueryClient(); + const client = createDashboardTrpcClient(transport); + const observer = new QueryObserver( + queryClient, + systemMetricsQueryOptions(client) + ); + const freshResult = Promise.withResolvers(); + const staleResult = Promise.withResolvers(); + const unsubscribe = observer.subscribe((result) => { + if (result.data?.freshness === "fresh") freshResult.resolve(); + if (result.data?.freshness === "stale" && transport.calls.length === 2) { + staleResult.resolve(); + } + }); + + try { + await freshResult.promise; + jest.advanceTimersByTime(systemMetricsRefreshIntervalMs); + await staleResult.promise; + + expect(systemMetricsQueryOptions(client).queryKey).toEqual( + systemMetricsQueryKey + ); + expect(systemMetricsQueryOptions(client).refetchOnMount).toBe("always"); + expect(transport.calls.map(({ input, path }) => ({ input, path }))).toEqual([ + { input: {}, path: "system.metrics" }, + { input: {}, path: "system.metrics" }, + ]); + expect( + transport.calls.every(({ signal }) => signal instanceof AbortSignal) + ).toBeTrue(); + } finally { + unsubscribe(); + queryClient.clear(); + jest.useRealTimers(); + } + }); + + test("aborts an in-flight request when its final observer unsubscribes", async () => { + const transport = new HangingSystemMetricsTransport(); + const queryClient = createDashboardQueryClient(); + const client = createDashboardTrpcClient(transport); + const observer = new QueryObserver( + queryClient, + systemMetricsQueryOptions(client) + ); + const unsubscribe = observer.subscribe(() => null); + + const signal = await transport.started.promise; + expect(signal.aborted).toBeFalse(); + + unsubscribe(); + await Promise.resolve(); + expect(signal.aborted).toBeTrue(); + queryClient.clear(); + }); +}); diff --git a/greenfield/src/browser/overview/systemMetricsQueries.ts b/greenfield/src/browser/overview/systemMetricsQueries.ts new file mode 100644 index 000000000..8bbe0745b --- /dev/null +++ b/greenfield/src/browser/overview/systemMetricsQueries.ts @@ -0,0 +1,19 @@ +import { queryOptions } from "@tanstack/react-query"; + +import type { SystemMetrics } from "../../contracts/system.ts"; +import type { DashboardTrpcClient } from "../api/trpcClient.ts"; + +export const systemMetricsQueryKey = ["system", "metrics"] as const; +export const systemMetricsRefreshIntervalMs = 5000; + +/** @returns Five-second demand-driven system metric query options. */ +export function systemMetricsQueryOptions(client: DashboardTrpcClient) { + return queryOptions({ + queryFn: ({ signal }): Promise => + client.query("system.metrics", {}, { signal }), + queryKey: systemMetricsQueryKey, + refetchInterval: systemMetricsRefreshIntervalMs, + refetchOnMount: "always", + staleTime: systemMetricsRefreshIntervalMs, + }); +} diff --git a/greenfield/src/browser/ui/MetricCard.test.tsx b/greenfield/src/browser/ui/MetricCard.test.tsx new file mode 100644 index 000000000..a2e8f7cc2 --- /dev/null +++ b/greenfield/src/browser/ui/MetricCard.test.tsx @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; + +import { Cpu } from "lucide-react"; + +import { MetricCard } from "./MetricCard.tsx"; + +const { render, screen } = await import("@testing-library/react"); + +describe("MetricCard", () => { + test("labels its value and clamps only the visual meter", () => { + render( + + ); + + expect(screen.getByRole("heading", { level: 3, name: "CPU" })).toBeTruthy(); + expect(screen.getByText("248.1%")).toBeTruthy(); + expect( + screen.getByRole("progressbar", { name: "CPU normalized load" }) + ).toHaveValue(100); + }); +}); diff --git a/greenfield/src/browser/ui/MetricCard.tsx b/greenfield/src/browser/ui/MetricCard.tsx new file mode 100644 index 000000000..0c1e08ab6 --- /dev/null +++ b/greenfield/src/browser/ui/MetricCard.tsx @@ -0,0 +1,66 @@ +import type { LucideIcon } from "lucide-react"; +import { type ReactNode, useId } from "react"; + +import { cn } from "../lib/classNames.ts"; +import { Card } from "./Card.tsx"; +import { Heading } from "./Heading.tsx"; +import { Icon } from "./Icon.tsx"; +import { Text } from "./Text.tsx"; + +interface MetricCardMeter { + readonly label: string; + readonly maximum: number; + readonly value: number; +} + +interface MetricCardProps { + readonly className?: string; + readonly description: ReactNode; + readonly icon: LucideIcon; + readonly meter?: MetricCardMeter; + readonly title: string; + readonly value: ReactNode; +} + +/** @returns One accessible operational value card with an optional bounded meter. */ +export function MetricCard({ + className, + description, + icon, + meter, + title, + value, +}: MetricCardProps) { + const headingId = useId(); + const maximum = Math.max(1, meter?.maximum ?? 1); + const meterValue = Math.min(maximum, Math.max(0, meter?.value ?? 0)); + + return ( + +
+
+ + {title} + +

+ {value} +

+
+ + + +
+ + {description} + + {meter !== undefined && ( + + )} +
+ ); +} diff --git a/greenfield/src/browser/ui/stories/MetricCard.stories.tsx b/greenfield/src/browser/ui/stories/MetricCard.stories.tsx new file mode 100644 index 000000000..77bbe0561 --- /dev/null +++ b/greenfield/src/browser/ui/stories/MetricCard.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/tanstack-react"; +import { Cpu } from "lucide-react"; +import { expect, within } from "storybook/test"; + +import { MetricCard } from "../MetricCard.tsx"; + +const meta = { + args: { + description: "1 minute load 9.92 · 4 logical cores", + icon: Cpu, + meter: { label: "CPU normalized load", maximum: 100, value: 248.1 }, + title: "CPU", + value: "248.1%", + }, + component: MetricCard, + parameters: { layout: "padded" }, + title: "UI/MetricCard", +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole("heading", { name: "CPU" })).toBeVisible(); + await expect( + canvas.getByRole("progressbar", { name: "CPU normalized load" }) + ).toHaveValue(100); + }, +}; + +export const WithoutMeter: Story = { + args: { + description: "Current receive throughput", + meter: undefined, + title: "Download", + value: "12.3 Mbit/s", + }, +}; diff --git a/greenfield/src/contracts/system.test.ts b/greenfield/src/contracts/system.test.ts new file mode 100644 index 000000000..80ae89e3a --- /dev/null +++ b/greenfield/src/contracts/system.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test"; + +import * as v from "valibot"; + +import { + type SystemMetrics, + systemMetricsContract, + systemMetricsSchema, +} from "./system.ts"; + +const metrics = Object.freeze({ + cpu: { + loadAverage: [2, 1, 0.5], + loadPercent: 50, + logicalCoreCount: 4, + }, + disk: { + freeBytes: 400, + totalBytes: 1000, + usedBytes: 600, + usedPercent: 60, + }, + freshness: "fresh", + memory: { + freeBytes: 250, + totalBytes: 1000, + usedBytes: 750, + usedPercent: 75, + }, + network: { + downloadBitsPerSecond: 800, + state: "ready", + uploadBitsPerSecond: 400, + }, + sampledAtMs: 1_800_000_000_000, + uptimeSeconds: 12, +} as const satisfies SystemMetrics); + +describe("system metrics contract", () => { + test("accepts only the bounded identity-free operational projection", () => { + expect(v.parse(systemMetricsSchema, metrics)).toEqual(metrics); + expect(() => + v.parse(systemMetricsSchema, { ...metrics, hostname: "private-host" }) + ).toThrow(); + expect(() => + v.parse(systemMetricsSchema, { + ...metrics, + cpu: { ...metrics.cpu, model: "private-model" }, + }) + ).toThrow(); + }); + + test("rejects inconsistent capacities and invalid rates", () => { + expect(() => + v.parse(systemMetricsSchema, { + ...metrics, + memory: { ...metrics.memory, usedBytes: 749 }, + }) + ).toThrow("capacity fields are inconsistent"); + expect(() => + v.parse(systemMetricsSchema, { + ...metrics, + network: { ...metrics.network, downloadBitsPerSecond: -1 }, + }) + ).toThrow(); + }); + + test("is browser-session-only without an automation capability", () => { + expect(systemMetricsContract.access).toEqual({ + capabilities: [], + capabilityPolicy: "all", + kind: "authenticated", + principalKinds: ["session"], + }); + expect(systemMetricsContract.errors).toEqual([ + "FORBIDDEN", + "SERVICE_UNAVAILABLE", + "UNAUTHORIZED", + ]); + }); +}); diff --git a/greenfield/src/contracts/system.ts b/greenfield/src/contracts/system.ts index 407d4b6cb..66ea46ed2 100644 --- a/greenfield/src/contracts/system.ts +++ b/greenfield/src/contracts/system.ts @@ -10,6 +10,76 @@ export const healthReadinessPath = "/api/health/ready"; /** Empty object accepted by procedures without user input. */ export const emptyInputSchema = v.optional(v.strictObject({}), {}); +const systemMetricByteCountSchema = v.pipe(v.number(), v.safeInteger(), v.minValue(0)); +const systemMetricPercentSchema = v.pipe(v.number(), v.minValue(0), v.maxValue(100)); +const systemMetricLoadSchema = v.pipe(v.number(), v.minValue(0), v.maxValue(100_000)); + +function roundedCapacityPercent(usedBytes: number, totalBytes: number): number { + return totalBytes === 0 ? 0 : Math.round((usedBytes / totalBytes) * 1000) / 10; +} + +/** + * @param capacity - Capacity fields emitted by the sampler. + * @returns Whether the bytes and rounded percentage describe one state. + */ +export function systemMetricCapacityIsConsistent(capacity: { + freeBytes: number; + totalBytes: number; + usedBytes: number; + usedPercent: number; +}): boolean { + return ( + capacity.freeBytes <= capacity.totalBytes && + capacity.usedBytes === capacity.totalBytes - capacity.freeBytes && + capacity.usedPercent === + roundedCapacityPercent(capacity.usedBytes, capacity.totalBytes) + ); +} + +/** Capacity projection with explicit byte and percentage units. */ +export const systemMetricCapacitySchema = v.pipe( + v.strictObject({ + freeBytes: systemMetricByteCountSchema, + totalBytes: systemMetricByteCountSchema, + usedBytes: systemMetricByteCountSchema, + usedPercent: systemMetricPercentSchema, + }), + v.check( + systemMetricCapacityIsConsistent, + "System metric capacity fields are inconsistent" + ) +); + +/** Bounded session-only host metrics returned to the operational overview. */ +export const systemMetricsSchema = v.strictObject({ + cpu: v.strictObject({ + loadAverage: v.tuple([ + systemMetricLoadSchema, + systemMetricLoadSchema, + systemMetricLoadSchema, + ]), + loadPercent: v.pipe(systemMetricLoadSchema, v.maxValue(10_000)), + logicalCoreCount: v.pipe( + v.number(), + v.safeInteger(), + v.minValue(1), + v.maxValue(8192) + ), + }), + disk: systemMetricCapacitySchema, + freshness: v.picklist(["fresh", "stale"]), + memory: systemMetricCapacitySchema, + network: v.strictObject({ + downloadBitsPerSecond: systemMetricByteCountSchema, + state: v.picklist(["ready", "warming"]), + uploadBitsPerSecond: systemMetricByteCountSchema, + }), + sampledAtMs: v.pipe(v.number(), v.safeInteger(), v.minValue(0)), + uptimeSeconds: v.pipe(v.number(), v.safeInteger(), v.minValue(0)), +}); + +export type SystemMetrics = v.InferOutput; + /** Public runtime identity returned by the system procedure. */ export const runtimeIdentitySchema = v.strictObject({ revision: v.pipe(v.string(), v.description("Full Bun Git revision.")), @@ -49,8 +119,36 @@ export const runtimeIdentityContract = { }, } as const satisfies ProcedureContract; +/** Session-only metrics contract without automation authority or host identity. */ +export const systemMetricsContract = { + access: { + capabilities: [], + capabilityPolicy: "all", + kind: "authenticated", + principalKinds: ["session"], + }, + domain: "system", + errors: ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], + input: emptyInputSchema, + inputSchemaId: "system.metrics.input", + kind: "query", + name: "system.metrics", + output: systemMetricsSchema, + outputSchemaId: "system.metrics.output", + summary: + "Returns bounded host gauges and throughput without host identity or control authority.", + transport: { + batching: "adapter-default", + handler: "default", + requestBody: "default", + }, +} as const satisfies ProcedureContract; + /** Implemented system tRPC contracts. */ -export const systemProcedureContracts = [runtimeIdentityContract] as const; +export const systemProcedureContracts = [ + systemMetricsContract, + runtimeIdentityContract, +] as const; /** Implemented raw health-route contracts. */ export const systemRawHttpContracts = [ diff --git a/greenfield/src/server/domains/system/procedures.test.ts b/greenfield/src/server/domains/system/procedures.test.ts new file mode 100644 index 000000000..4eb9949f3 --- /dev/null +++ b/greenfield/src/server/domains/system/procedures.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test"; + +import { TRPCError } from "@trpc/server"; + +import type { SystemMetrics } from "../../../contracts/system.ts"; +import { captureFailure } from "../../test/support/promise.ts"; +import { + createTestApplicationRuntime, + createTestAutomationAuthentication, + createTestRequestContext, + createTestSessionAuthentication, +} from "../../test/support/requestContext.ts"; +import { appRouter } from "../../trpc/appRouter.ts"; +import { + SystemMetricsUnavailableError, + type SystemMetricsRuntimeService, +} from "./systemMetricsService.ts"; + +const metrics = Object.freeze({ + cpu: { + loadAverage: [2, 1, 0.5], + loadPercent: 50, + logicalCoreCount: 4, + }, + disk: { + freeBytes: 400, + totalBytes: 1000, + usedBytes: 600, + usedPercent: 60, + }, + freshness: "fresh", + memory: { + freeBytes: 250, + totalBytes: 1000, + usedBytes: 750, + usedPercent: 75, + }, + network: { + downloadBitsPerSecond: 800, + state: "ready", + uploadBitsPerSecond: 400, + }, + sampledAtMs: 1_800_000_000_000, + uptimeSeconds: 12, +} as const satisfies SystemMetrics); + +async function caller( + authentication = createTestSessionAuthentication([]), + systemMetrics: SystemMetricsRuntimeService = Object.freeze({ + read: () => Promise.resolve(metrics), + }) +) { + const context = await createTestRequestContext( + authentication, + createTestApplicationRuntime({ systemMetrics }) + ); + return appRouter.createCaller(context).system; +} + +describe("system metrics procedure", () => { + test("returns the runtime snapshot to an authenticated browser session", async () => { + const system = await caller(); + expect(await system.metrics()).toEqual(metrics); + }); + + test("rejects anonymous and automation principals before reading metrics", async () => { + let readCount = 0; + const systemMetrics = Object.freeze({ + read: () => { + readCount += 1; + return Promise.resolve(metrics); + }, + }); + const anonymous = await caller({ kind: "anonymous" }, systemMetrics); + const automation = await caller( + createTestAutomationAuthentication([]), + systemMetrics + ); + const anonymousFailure = await captureFailure(() => anonymous.metrics()); + const automationFailure = await captureFailure(() => automation.metrics()); + + expect(anonymousFailure).toMatchObject({ code: "UNAUTHORIZED" }); + expect(automationFailure).toMatchObject({ code: "FORBIDDEN" }); + expect(readCount).toBe(0); + }); + + test("maps only typed collection outages to a fixed safe failure", async () => { + const rawFailure = new TypeError("private /proc failure detail"); + const system = await caller( + createTestSessionAuthentication([]), + Object.freeze({ + read: () => Promise.reject(new SystemMetricsUnavailableError(rawFailure)), + }) + ); + const failure = await captureFailure(() => system.metrics()); + + expect(failure).toBeInstanceOf(TRPCError); + expect(failure).toMatchObject({ + code: "SERVICE_UNAVAILABLE", + message: "System metrics are temporarily unavailable", + }); + expect((failure as Error).message).not.toContain(rawFailure.message); + }); +}); diff --git a/greenfield/src/server/domains/system/procedures.ts b/greenfield/src/server/domains/system/procedures.ts index deeac1d8a..b7dbbc339 100644 --- a/greenfield/src/server/domains/system/procedures.ts +++ b/greenfield/src/server/domains/system/procedures.ts @@ -1,8 +1,29 @@ -import { runtimeIdentityContract } from "../../../contracts/system.ts"; +import { TRPCError } from "@trpc/server"; + +import { + runtimeIdentityContract, + systemMetricsContract, +} from "../../../contracts/system.ts"; import { readRuntimeIdentity } from "../../platform/runtime/readRuntimeIdentity.ts"; -import { publicProcedure, router } from "../../trpc/trpc.ts"; +import { publicProcedure, router, sessionProcedure } from "../../trpc/trpc.ts"; +import { SystemMetricsUnavailableError } from "./systemMetricsService.ts"; const systemRoutes = { + metrics: sessionProcedure + .input(systemMetricsContract.input) + .output(systemMetricsContract.output) + .query(async ({ ctx }) => { + try { + return await ctx.services.systemMetrics.read(); + } catch (error) { + if (!(error instanceof SystemMetricsUnavailableError)) throw error; + throw new TRPCError({ + cause: error, + code: "SERVICE_UNAVAILABLE", + message: "System metrics are temporarily unavailable", + }); + } + }), runtimeIdentity: publicProcedure .input(runtimeIdentityContract.input) .output(runtimeIdentityContract.output) @@ -12,5 +33,5 @@ const systemRoutes = { /** Leaf procedure names owned by the system-router composition. */ export const systemProcedureNames = Object.freeze(Object.keys(systemRoutes)); -/** Public system procedures implemented by the foundation slice. */ +/** Public identity and session-only system metric procedures. */ export const systemRouter = router(systemRoutes); diff --git a/greenfield/src/server/domains/system/systemMetricsCollector.test.ts b/greenfield/src/server/domains/system/systemMetricsCollector.test.ts new file mode 100644 index 000000000..b49a11221 --- /dev/null +++ b/greenfield/src/server/domains/system/systemMetricsCollector.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test"; + +import { + createSystemMetricsSampler, + parseLinuxNetworkCounters, + type SystemMetricsAdapter, +} from "./systemMetricsCollector.ts"; + +function adapter(overrides: Partial = {}): SystemMetricsAdapter { + return { + freeMemoryBytes: () => 250, + loadAverage: () => [2, 1, 0.5], + logicalCoreCount: () => 4, + networkCounters: () => + Promise.resolve({ downloadBytes: 100n, uploadBytes: 200n }), + nowMs: () => 1000, + rootFilesystem: () => Promise.resolve({ bavail: 4n, blocks: 10n, bsize: 100n }), + totalMemoryBytes: () => 1000, + uptimeSeconds: () => 12.9, + ...overrides, + }; +} + +describe("system metrics collector", () => { + test("collects bounded gauges and warms aggregate network rates without identity", async () => { + const times = [1000, 6000]; + const counters = [ + { downloadBytes: 100n, uploadBytes: 200n }, + { downloadBytes: 600n, uploadBytes: 450n }, + ]; + let index = 0; + const sample = createSystemMetricsSampler( + adapter({ + networkCounters: () => Promise.resolve(counters[index]!), + nowMs: () => times[index++]!, + }) + ); + + const first = await sample(); + const second = await sample(); + + expect(first).toEqual({ + cpu: { + loadAverage: [2, 1, 0.5], + loadPercent: 50, + logicalCoreCount: 4, + }, + disk: { + freeBytes: 400, + totalBytes: 1000, + usedBytes: 600, + usedPercent: 60, + }, + freshness: "fresh", + memory: { + freeBytes: 250, + totalBytes: 1000, + usedBytes: 750, + usedPercent: 75, + }, + network: { + downloadBitsPerSecond: 0, + state: "warming", + uploadBitsPerSecond: 0, + }, + sampledAtMs: 1000, + uptimeSeconds: 12, + }); + expect(second.network).toEqual({ + downloadBitsPerSecond: 800, + state: "ready", + uploadBitsPerSecond: 400, + }); + expect("hostname" in second).toBe(false); + expect("model" in second.cpu).toBe(false); + }); + + test("aggregates non-loopback Linux counters without retaining names", () => { + expect( + parseLinuxNetworkCounters(`Inter-| Receive | Transmit + lo: 10 0 0 0 0 0 0 0 20 0 0 0 0 0 0 0 + eth0: 100 0 0 0 0 0 0 0 200 0 0 0 0 0 0 0 + docker0: 50 0 0 0 0 0 0 0 75 0 0 0 0 0 0 0`) + ).toEqual({ downloadBytes: 150n, uploadBytes: 275n }); + expect(() => parseLinuxNetworkCounters("eth0: invalid")).toThrow(); + expect(() => + parseLinuxNetworkCounters("lo: 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0") + ).toThrow("unavailable"); + }); + + test("warms after counter resets and clock regressions before recovering", async () => { + const times = [1000, 2000, 3000, 3000, 2500, 3500]; + const counters = [100n, 200n, 50n, 100n, 150n, 250n].map((value) => ({ + downloadBytes: value, + uploadBytes: value, + })); + let index = 0; + const sample = createSystemMetricsSampler( + adapter({ + networkCounters: () => Promise.resolve(counters[index]!), + nowMs: () => times[index++]!, + }) + ); + + const first = await sample(); + const second = await sample(); + const reset = await sample(); + const equalClock = await sample(); + const regressedClock = await sample(); + const recovered = await sample(); + + expect([ + first.network.state, + second.network.state, + reset.network.state, + equalClock.network.state, + regressedClock.network.state, + ]).toEqual(["warming", "ready", "warming", "warming", "warming"]); + expect(recovered.network).toEqual({ + downloadBitsPerSecond: 800, + state: "ready", + uploadBitsPerSecond: 800, + }); + }); + + test("rejects a network rate outside the safe integer range", async () => { + const times = [1000, 1001]; + const counters = [0n, BigInt(Number.MAX_SAFE_INTEGER)].map((value) => ({ + downloadBytes: value, + uploadBytes: value, + })); + let index = 0; + const sample = createSystemMetricsSampler( + adapter({ + networkCounters: () => Promise.resolve(counters[index]!), + nowMs: () => times[index++]!, + }) + ); + + await sample(); + expect(sample()).rejects.toThrow("rate is outside the safe range"); + }); + + test("rejects inconsistent and unsafe host values", () => { + expect( + createSystemMetricsSampler(adapter({ freeMemoryBytes: () => 1001 }))() + ).rejects.toThrow(); + expect( + createSystemMetricsSampler( + adapter({ + rootFilesystem: () => + Promise.resolve({ + bavail: 1n, + blocks: BigInt(Number.MAX_SAFE_INTEGER) + 1n, + bsize: 1n, + }), + }) + )() + ).rejects.toThrow("outside the safe integer range"); + }); +}); diff --git a/greenfield/src/server/domains/system/systemMetricsCollector.ts b/greenfield/src/server/domains/system/systemMetricsCollector.ts new file mode 100644 index 000000000..609f5ead8 --- /dev/null +++ b/greenfield/src/server/domains/system/systemMetricsCollector.ts @@ -0,0 +1,248 @@ +import { readFile, statfs } from "node:fs/promises"; +import { + availableParallelism, + freemem, + loadavg, + platform, + totalmem, + uptime, +} from "node:os"; + +import * as v from "valibot"; + +import { type SystemMetrics, systemMetricsSchema } from "../../../contracts/system.ts"; + +interface SystemMetricsFilesystemStats { + readonly bavail: bigint; + readonly blocks: bigint; + readonly bsize: bigint; +} + +export interface SystemMetricsNetworkCounters { + readonly downloadBytes: bigint; + readonly uploadBytes: bigint; +} + +export interface SystemMetricsAdapter { + freeMemoryBytes(): number; + loadAverage(): readonly number[]; + logicalCoreCount(): number; + networkCounters(): Promise; + nowMs(): number; + rootFilesystem(): Promise; + totalMemoryBytes(): number; + uptimeSeconds(): number; +} + +interface NetworkBaseline extends SystemMetricsNetworkCounters { + readonly sampledAtMs: number; +} + +/** One demand-driven metrics collection operation. */ +export type SystemMetricsSampler = () => Promise; + +function parseCounter(value: string, field: string): bigint { + if (!/^\d+$/u.test(value)) { + throw new TypeError(`System metrics ${field} counter is invalid`); + } + return BigInt(value); +} + +/** + * Parses aggregate non-loopback Linux counters without retaining interface identity. + * @param text Kernel-owned /proc/net/dev projection. + * @returns Aggregate receive and transmit byte counters. + */ +export function parseLinuxNetworkCounters(text: string): SystemMetricsNetworkCounters { + let downloadBytes = 0n; + let uploadBytes = 0n; + let interfaceCount = 0; + + for (const line of text.split("\n")) { + const separator = line.indexOf(":"); + if (separator === -1) continue; + const name = line.slice(0, separator).trim(); + if (name === "lo") continue; + if (name.length === 0) { + throw new TypeError("System metrics network interface is invalid"); + } + const fields = line + .slice(separator + 1) + .trim() + .split(/\s+/u); + if (fields.length < 9 || fields[0] === undefined || fields[8] === undefined) { + throw new TypeError("System metrics network counters are incomplete"); + } + downloadBytes += parseCounter(fields[0], "download"); + uploadBytes += parseCounter(fields[8], "upload"); + interfaceCount += 1; + } + + if (interfaceCount === 0) { + throw new TypeError("System metrics network counters are unavailable"); + } + return { downloadBytes, uploadBytes }; +} + +async function readDefaultNetworkCounters(): Promise { + if (platform() !== "linux") { + throw new TypeError("System metrics network counters require Linux"); + } + return parseLinuxNetworkCounters(await readFile("/proc/net/dev", "utf8")); +} + +const defaultSystemMetricsAdapter: SystemMetricsAdapter = Object.freeze({ + freeMemoryBytes: freemem, + loadAverage: loadavg, + logicalCoreCount: availableParallelism, + networkCounters: readDefaultNetworkCounters, + nowMs: Date.now, + rootFilesystem: () => statfs("/", { bigint: true }), + totalMemoryBytes: totalmem, + uptimeSeconds: uptime, +}); + +function safeByteCount(value: bigint, field: string): number { + if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new RangeError(`System metrics ${field} is outside the safe integer range`); + } + return Number(value); +} + +function safeNonnegativeInteger(value: number, field: string): number { + const integer = Math.floor(value); + if (!Number.isSafeInteger(integer) || integer < 0) { + throw new RangeError(`System metrics ${field} is outside the safe integer range`); + } + return integer; +} + +function rounded(value: number, digits: number): number { + if (!Number.isFinite(value) || value < 0) { + throw new RangeError("System metrics numeric sample is invalid"); + } + const scale = 10 ** digits; + return Math.round(value * scale) / scale; +} + +function capacity(totalBytes: number, freeBytes: number): SystemMetrics["memory"] { + const usedBytes = totalBytes - freeBytes; + return { + freeBytes, + totalBytes, + usedBytes, + usedPercent: + totalBytes === 0 ? 0 : Math.round((usedBytes / totalBytes) * 1000) / 10, + }; +} + +function bitsPerSecond(deltaBytes: bigint, elapsedMs: number, field: string): number { + if (deltaBytes < 0n || deltaBytes > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new RangeError(`System metrics ${field} delta is outside the safe range`); + } + const rate = Math.round((Number(deltaBytes) * 8000) / elapsedMs); + if (!Number.isSafeInteger(rate) || rate < 0) { + throw new RangeError(`System metrics ${field} rate is outside the safe range`); + } + return rate; +} + +function networkProjection( + counters: SystemMetricsNetworkCounters, + sampledAtMs: number, + previous: NetworkBaseline | undefined +): SystemMetrics["network"] { + if ( + previous === undefined || + sampledAtMs <= previous.sampledAtMs || + counters.downloadBytes < previous.downloadBytes || + counters.uploadBytes < previous.uploadBytes + ) { + return { + downloadBitsPerSecond: 0, + state: "warming", + uploadBitsPerSecond: 0, + }; + } + const elapsedMs = sampledAtMs - previous.sampledAtMs; + return { + downloadBitsPerSecond: bitsPerSecond( + counters.downloadBytes - previous.downloadBytes, + elapsedMs, + "download" + ), + state: "ready", + uploadBitsPerSecond: bitsPerSecond( + counters.uploadBytes - previous.uploadBytes, + elapsedMs, + "upload" + ), + }; +} + +/** + * Creates one stateful, shell-free sampler for bounded host gauges and throughput. + * Network rates become ready only after two monotonic successful samples. + * @param adapter Injectable host boundary. + * @returns One demand-driven sampler retaining only aggregate network counters. + */ +export function createSystemMetricsSampler( + adapter: SystemMetricsAdapter = defaultSystemMetricsAdapter +): SystemMetricsSampler { + let previousNetwork: NetworkBaseline | undefined; + + return async () => { + const [filesystem, networkCounters] = await Promise.all([ + adapter.rootFilesystem(), + adapter.networkCounters(), + ]); + const sampledAtMs = safeNonnegativeInteger(adapter.nowMs(), "sample time"); + const logicalCoreCount = safeNonnegativeInteger( + adapter.logicalCoreCount(), + "logical core count" + ); + const loadAverage = adapter.loadAverage(); + const totalMemoryBytes = safeNonnegativeInteger( + adapter.totalMemoryBytes(), + "memory total" + ); + const freeMemoryBytes = safeNonnegativeInteger( + adapter.freeMemoryBytes(), + "memory free" + ); + const diskTotalBytes = safeByteCount( + filesystem.bsize * filesystem.blocks, + "disk total" + ); + const diskFreeBytes = safeByteCount( + filesystem.bsize * filesystem.bavail, + "disk free" + ); + const firstLoad = loadAverage[0]; + const metrics = v.parse(systemMetricsSchema, { + cpu: { + loadAverage: [ + rounded(loadAverage[0] ?? Number.NaN, 2), + rounded(loadAverage[1] ?? Number.NaN, 2), + rounded(loadAverage[2] ?? Number.NaN, 2), + ], + loadPercent: + logicalCoreCount === 0 + ? Number.NaN + : rounded( + ((firstLoad ?? Number.NaN) / logicalCoreCount) * 100, + 1 + ), + logicalCoreCount, + }, + disk: capacity(diskTotalBytes, diskFreeBytes), + freshness: "fresh", + memory: capacity(totalMemoryBytes, freeMemoryBytes), + network: networkProjection(networkCounters, sampledAtMs, previousNetwork), + sampledAtMs, + uptimeSeconds: safeNonnegativeInteger(adapter.uptimeSeconds(), "uptime"), + }); + previousNetwork = { ...networkCounters, sampledAtMs }; + return metrics; + }; +} diff --git a/greenfield/src/server/domains/system/systemMetricsService.test.ts b/greenfield/src/server/domains/system/systemMetricsService.test.ts new file mode 100644 index 000000000..091bb2582 --- /dev/null +++ b/greenfield/src/server/domains/system/systemMetricsService.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; + +import type { SystemMetrics } from "../../../contracts/system.ts"; +import { + createSystemMetricsRuntimeService, + SystemMetricsUnavailableError, +} from "./systemMetricsService.ts"; + +function metrics(sampledAtMs = 1000): SystemMetrics { + return { + cpu: { + loadAverage: [2, 1, 0.5], + loadPercent: 50, + logicalCoreCount: 4, + }, + disk: { + freeBytes: 400, + totalBytes: 1000, + usedBytes: 600, + usedPercent: 60, + }, + freshness: "fresh", + memory: { + freeBytes: 250, + totalBytes: 1000, + usedBytes: 750, + usedPercent: 75, + }, + network: { + downloadBitsPerSecond: 800, + state: "ready", + uploadBitsPerSecond: 400, + }, + sampledAtMs, + uptimeSeconds: 12, + }; +} + +describe("system metrics runtime service", () => { + test("coalesces concurrent demand into one sample", async () => { + const sampleResult = Promise.withResolvers(); + let sampleCount = 0; + const service = createSystemMetricsRuntimeService({ + sample: () => { + sampleCount += 1; + return sampleResult.promise; + }, + }); + + const first = service.read(); + const second = service.read(); + expect(first).toBe(second); + expect(sampleCount).toBe(1); + + sampleResult.resolve(metrics()); + expect(await first).toEqual(metrics()); + expect(await second).toEqual(metrics()); + expect(Object.isFrozen(service)).toBe(true); + }); + + test("serves a marked last-known-good sample for at most thirty seconds", async () => { + const rawFailure = new TypeError("private collector failure"); + const fallbackTimes = [31_000, 31_001]; + let sampleCount = 0; + let fallbackIndex = 0; + const service = createSystemMetricsRuntimeService({ + nowMs: () => fallbackTimes[fallbackIndex++]!, + sample: () => { + sampleCount += 1; + return sampleCount === 1 + ? Promise.resolve(metrics()) + : Promise.reject(rawFailure); + }, + }); + + const fresh = await service.read(); + expect(fresh.freshness).toBe("fresh"); + expect(await service.read()).toEqual({ ...metrics(), freshness: "stale" }); + + const failure = await service.read().catch((error: unknown) => error); + expect(failure).toBeInstanceOf(SystemMetricsUnavailableError); + expect((failure as Error).message).not.toContain(rawFailure.message); + expect(sampleCount).toBe(3); + }); + + test("fails with one typed error when no valid sample exists", async () => { + const service = createSystemMetricsRuntimeService({ + sample: () => + Promise.resolve({ + ...metrics(), + freshness: "stale", + }), + }); + + const failure = await service.read().catch((error: unknown) => error); + expect(failure).toBeInstanceOf(SystemMetricsUnavailableError); + }); +}); diff --git a/greenfield/src/server/domains/system/systemMetricsService.ts b/greenfield/src/server/domains/system/systemMetricsService.ts new file mode 100644 index 000000000..1b6fb1e04 --- /dev/null +++ b/greenfield/src/server/domains/system/systemMetricsService.ts @@ -0,0 +1,97 @@ +import * as v from "valibot"; + +import { type SystemMetrics, systemMetricsSchema } from "../../../contracts/system.ts"; +import { + createSystemMetricsSampler, + type SystemMetricsSampler, +} from "./systemMetricsCollector.ts"; + +/** Maximum age of a successful sample returned after a refresh failure. */ +export const systemMetricsLastKnownGoodMs = 30_000; + +/** Expected operational failure after no eligible metrics snapshot remains. */ +export class SystemMetricsUnavailableError extends Error { + public constructor(cause?: unknown) { + super("System metrics are unavailable", { cause }); + this.name = "SystemMetricsUnavailableError"; + } +} + +/** Request-safe process service for one coalesced demand-driven metrics snapshot. */ +export interface SystemMetricsRuntimeService { + read(): Promise; +} + +export interface SystemMetricsRuntimeServiceOptions { + readonly nowMs?: () => number; + readonly sample?: SystemMetricsSampler; + readonly staleForMs?: number; +} + +function validClockValue(nowMs: () => number): number { + const value = nowMs(); + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError("System metrics clock is outside the safe integer range"); + } + return value; +} + +/** + * Creates a single-flight metrics service with a bounded last-known-good window. + * Successful reads always attempt a new sample; callers arriving together share it. + * @param options Injectable sampler, clock, and stale window. + * @returns One immutable request-safe metrics port. + */ +export function createSystemMetricsRuntimeService( + options: SystemMetricsRuntimeServiceOptions = {} +): SystemMetricsRuntimeService { + const nowMs = options.nowMs ?? Date.now; + const sample = options.sample ?? createSystemMetricsSampler(); + const staleForMs = options.staleForMs ?? systemMetricsLastKnownGoodMs; + if (!Number.isSafeInteger(staleForMs) || staleForMs < 0) { + throw new RangeError("System metrics stale window is invalid"); + } + + let inFlight: Promise | undefined; + let lastKnownGood: SystemMetrics | undefined; + + const load = async (): Promise => { + try { + const fresh = v.parse(systemMetricsSchema, await sample()); + if (fresh.freshness !== "fresh") { + throw new TypeError("System metrics sampler returned a stale snapshot"); + } + lastKnownGood = fresh; + return fresh; + } catch (error) { + try { + if (lastKnownGood !== undefined) { + const now = validClockValue(nowMs); + const ageMs = now - lastKnownGood.sampledAtMs; + if (ageMs >= 0 && ageMs <= staleForMs) { + return v.parse(systemMetricsSchema, { + ...lastKnownGood, + freshness: "stale", + }); + } + } + } catch { + throw new SystemMetricsUnavailableError(error); + } + throw new SystemMetricsUnavailableError(error); + } + }; + + return Object.freeze({ + read() { + if (inFlight !== undefined) return inFlight; + const current = load(); + inFlight = current; + const clear = () => { + if (inFlight === current) inFlight = undefined; + }; + void current.then(clear, clear); + return current; + }, + }); +} diff --git a/greenfield/src/server/platform/runtime/applicationRuntime.test.ts b/greenfield/src/server/platform/runtime/applicationRuntime.test.ts index 12e9f5032..ccef7d547 100644 --- a/greenfield/src/server/platform/runtime/applicationRuntime.test.ts +++ b/greenfield/src/server/platform/runtime/applicationRuntime.test.ts @@ -347,6 +347,7 @@ describe("application Effect runtime", () => { expect(Object.isFrozen(runtime)).toBe(true); expect(Object.isFrozen(runtime.services)).toBe(true); expect(Object.isFrozen(runtime.services.realtimeEvents)).toBe(true); + expect(Object.isFrozen(runtime.services.systemMetrics)).toBe(true); const values = await Array.fromAsync( await runtime.services.realtimeEvents.stream( diff --git a/greenfield/src/server/platform/runtime/applicationRuntime.ts b/greenfield/src/server/platform/runtime/applicationRuntime.ts index 81f813555..a591c6d16 100644 --- a/greenfield/src/server/platform/runtime/applicationRuntime.ts +++ b/greenfield/src/server/platform/runtime/applicationRuntime.ts @@ -28,6 +28,10 @@ import { authenticationWorkLayer, type AuthenticationVerificationWorkOptions, } from "../../domains/security/authenticationWorkGate.ts"; +import { + createSystemMetricsRuntimeService, + type SystemMetricsRuntimeService, +} from "../../domains/system/systemMetricsService.ts"; import { createEffectLoggerLayer } from "../observability/effectLogger.ts"; import type { StructuredLogger } from "../observability/structuredLogger.ts"; import { RealtimeEventPump, type RealtimeEventDelivery } from "../realtime/eventPump.ts"; @@ -56,6 +60,7 @@ export interface RealtimeEventRuntimeService { export interface ApplicationRuntimeServices { readonly authentication: AuthenticationWorkRuntimeService; readonly realtimeEvents: RealtimeEventRuntimeService; + readonly systemMetrics: SystemMetricsRuntimeService; } export type ApplicationListenerStopOperation = "force" | "graceful"; @@ -270,6 +275,7 @@ function createApplicationRuntimeFromManagedRuntime( throw error; } }; + const systemMetrics = createSystemMetricsRuntimeService(); const services: ApplicationRuntimeServices = Object.freeze({ authentication: Object.freeze({ passwordWorkGate: Object.freeze({ @@ -382,6 +388,7 @@ function createApplicationRuntimeFromManagedRuntime( ); }, }), + systemMetrics, }); return Object.freeze({ diff --git a/greenfield/src/server/test/support/requestContext.ts b/greenfield/src/server/test/support/requestContext.ts index b9b6168b5..19b35bb57 100644 --- a/greenfield/src/server/test/support/requestContext.ts +++ b/greenfield/src/server/test/support/requestContext.ts @@ -27,6 +27,10 @@ import type { AutomationSecurityLifecycleService } from "../../domains/security/ import type { MfaAccountLifecycleService } from "../../domains/security/mfa/accountLifecycle.ts"; import type { MfaLoginLifecycleService } from "../../domains/security/mfa/loginLifecycle.ts"; import type { SecurityAuditLifecycleService } from "../../domains/security/securityAuditLifecycle.ts"; +import { + SystemMetricsUnavailableError, + type SystemMetricsRuntimeService, +} from "../../domains/system/systemMetricsService.ts"; import type { TaskService } from "../../domains/tasks/service.ts"; import { createTestTaskService } from "../../domains/tasks/testSupport/service.ts"; import { @@ -196,6 +200,7 @@ interface TestApplicationRuntimeOverrides { readonly logger?: StructuredLogger; readonly shutdownListener?: ApplicationRuntime["shutdownListener"]; readonly stream?: RealtimeEventRuntimeService["stream"]; + readonly systemMetrics?: SystemMetricsRuntimeService; } const inertAuthenticationWorkGate: AuthenticationWorkRuntimeService["passwordWorkGate"] = @@ -227,6 +232,10 @@ const inertAuthenticationRuntime: AuthenticationWorkRuntimeService = Object.free runWebAuthnVerification: runInertAuthenticationVerification, }); +const inertSystemMetricsRuntime: SystemMetricsRuntimeService = Object.freeze({ + read: () => Promise.reject(new SystemMetricsUnavailableError()), +}); + /** * Creates an inert mutable-auth service that individual tests can override. * @param overrides Lifecycle methods exercised by the current test. @@ -463,6 +472,7 @@ export function createTestApplicationRuntime( )), wake: () => Promise.resolve(), }), + systemMetrics: overrides.systemMetrics ?? inertSystemMetricsRuntime, }), shutdownListener: overrides.shutdownListener ?? diff --git a/greenfield/src/server/trpc/procedureErrorPolicy.ts b/greenfield/src/server/trpc/procedureErrorPolicy.ts index ff5430e43..ff2b56f02 100644 --- a/greenfield/src/server/trpc/procedureErrorPolicy.ts +++ b/greenfield/src/server/trpc/procedureErrorPolicy.ts @@ -297,6 +297,7 @@ export const procedureExpectedErrorPolicy = freezeProcedureExpectedErrorPolicy({ "UNAUTHORIZED", ], "securityAudit.listEvents": ["FORBIDDEN", "UNAUTHORIZED"], + "system.metrics": ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], "system.runtimeIdentity": [], "tasks.addUpdate": [ "CONFLICT", diff --git a/greenfield/src/test/parity/fixtures/greenfield-contracts.json b/greenfield/src/test/parity/fixtures/greenfield-contracts.json index 6c334a438..7a1fc6bac 100644 --- a/greenfield/src/test/parity/fixtures/greenfield-contracts.json +++ b/greenfield/src/test/parity/fixtures/greenfield-contracts.json @@ -278,6 +278,10 @@ "kind": "query", "name": "securityAudit.listEvents" }, + { + "kind": "query", + "name": "system.metrics" + }, { "kind": "query", "name": "system.runtimeIdentity"