diff --git a/src/engineering_platform/assets/dashboard.js b/src/engineering_platform/assets/dashboard.js index 3afd90c4..6c0d660a 100644 --- a/src/engineering_platform/assets/dashboard.js +++ b/src/engineering_platform/assets/dashboard.js @@ -3598,6 +3598,13 @@ function showComponentModal(payload) { componentDetailField(fields, t("component.current_memory"), componentMemory(payload.processes)); } else { componentDetailField(fields, t("component.process_status"), componentProcessStatus(payload)); + if (payload.process_state === "IN_PROCESS") { + componentDetailField( + fields, + t("component.ep_server_uptime"), + formatComponentUptime(payload.process_host?.uptime_seconds), + ); + } } componentDetailField(fields, t("component.runtime_path"), installation.runtime_path); componentDetailField(fields, t("component.central_data_path"), installation.central_data_path); diff --git a/src/engineering_platform/assets/dashboard_locales.mjs b/src/engineering_platform/assets/dashboard_locales.mjs index f1bd9c94..f1515147 100644 --- a/src/engineering_platform/assets/dashboard_locales.mjs +++ b/src/engineering_platform/assets/dashboard_locales.mjs @@ -3739,11 +3739,11 @@ Object.assign(DASHBOARD_MESSAGES.nl, {"component.ep_server":"EP-server","compone Object.assign(DASHBOARD_MESSAGES.de, {"component.ep_server":"EP-Server","component.platform_database":"Plattformdatenbank","component.lifecycle_worker":"Lebenszyklus-Worker","component.operations_console":"Operations-Konsole","component.dashboard_relay":"Dashboard-Relay"}); Object.assign(DASHBOARD_MESSAGES.fr, {"component.ep_server":"Serveur EP","component.platform_database":"Base de données de plateforme","component.lifecycle_worker":"Worker de cycle de vie","component.operations_console":"Console des opérations","component.dashboard_relay":"Relais du tableau de bord"}); Object.assign(DASHBOARD_MESSAGES.es, {"component.ep_server":"Servidor EP","component.platform_database":"Base de datos de plataforma","component.lifecycle_worker":"Trabajador de ciclo de vida","component.operations_console":"Consola de operaciones","component.dashboard_relay":"Relé del panel"}); -Object.assign(DASHBOARD_MESSAGES.en, {"component.memory_unavailable":"Memory unavailable","component.owned_process_unavailable":"No active LaunchAgent process","component.process_status":"Process status","component.runs_in_ep_server":"Runs in EP Server (PID {pid})","component.runs_in_ep_server_unavailable":"Runs in EP Server; process status unavailable","component.storage_no_process":"Storage component; no process"}); -Object.assign(DASHBOARD_MESSAGES.nl, {"component.memory_unavailable":"Geheugen niet beschikbaar","component.owned_process_unavailable":"Geen actief LaunchAgent-proces","component.process_status":"Processtatus","component.runs_in_ep_server":"Draait in EP-server (PID {pid})","component.runs_in_ep_server_unavailable":"Draait in EP-server; processtatus niet beschikbaar","component.storage_no_process":"Opslagcomponent; geen proces"}); -Object.assign(DASHBOARD_MESSAGES.de, {"component.memory_unavailable":"Speicher nicht verfügbar","component.owned_process_unavailable":"Kein aktiver LaunchAgent-Prozess","component.process_status":"Prozessstatus","component.runs_in_ep_server":"Läuft im EP-Server (PID {pid})","component.runs_in_ep_server_unavailable":"Läuft im EP-Server; Prozessstatus nicht verfügbar","component.storage_no_process":"Speicherkomponente; kein Prozess"}); -Object.assign(DASHBOARD_MESSAGES.fr, {"component.memory_unavailable":"Mémoire indisponible","component.owned_process_unavailable":"Aucun processus LaunchAgent actif","component.process_status":"État du processus","component.runs_in_ep_server":"S’exécute dans le serveur EP (PID {pid})","component.runs_in_ep_server_unavailable":"S’exécute dans le serveur EP ; état indisponible","component.storage_no_process":"Composant de stockage ; aucun processus"}); -Object.assign(DASHBOARD_MESSAGES.es, {"component.memory_unavailable":"Memoria no disponible","component.owned_process_unavailable":"No hay proceso LaunchAgent activo","component.process_status":"Estado del proceso","component.runs_in_ep_server":"Se ejecuta en el servidor EP (PID {pid})","component.runs_in_ep_server_unavailable":"Se ejecuta en el servidor EP; estado no disponible","component.storage_no_process":"Componente de almacenamiento; sin proceso"}); +Object.assign(DASHBOARD_MESSAGES.en, {"component.ep_server_uptime":"Shared EP Server uptime","component.memory_unavailable":"Memory unavailable","component.owned_process_unavailable":"No active LaunchAgent process","component.process_status":"Process status","component.runs_in_ep_server":"Runs in EP Server (PID {pid})","component.runs_in_ep_server_unavailable":"Runs in EP Server; process status unavailable","component.storage_no_process":"Storage component; no process"}); +Object.assign(DASHBOARD_MESSAGES.nl, {"component.ep_server_uptime":"Gedeelde EP-server-uptime","component.memory_unavailable":"Geheugen niet beschikbaar","component.owned_process_unavailable":"Geen actief LaunchAgent-proces","component.process_status":"Processtatus","component.runs_in_ep_server":"Draait in EP-server (PID {pid})","component.runs_in_ep_server_unavailable":"Draait in EP-server; processtatus niet beschikbaar","component.storage_no_process":"Opslagcomponent; geen proces"}); +Object.assign(DASHBOARD_MESSAGES.de, {"component.ep_server_uptime":"Geteilte EP-Server-Laufzeit","component.memory_unavailable":"Speicher nicht verfügbar","component.owned_process_unavailable":"Kein aktiver LaunchAgent-Prozess","component.process_status":"Prozessstatus","component.runs_in_ep_server":"Läuft im EP-Server (PID {pid})","component.runs_in_ep_server_unavailable":"Läuft im EP-Server; Prozessstatus nicht verfügbar","component.storage_no_process":"Speicherkomponente; kein Prozess"}); +Object.assign(DASHBOARD_MESSAGES.fr, {"component.ep_server_uptime":"Durée de fonctionnement partagée du serveur EP","component.memory_unavailable":"Mémoire indisponible","component.owned_process_unavailable":"Aucun processus LaunchAgent actif","component.process_status":"État du processus","component.runs_in_ep_server":"S’exécute dans le serveur EP (PID {pid})","component.runs_in_ep_server_unavailable":"S’exécute dans le serveur EP ; état indisponible","component.storage_no_process":"Composant de stockage ; aucun processus"}); +Object.assign(DASHBOARD_MESSAGES.es, {"component.ep_server_uptime":"Tiempo de actividad compartido del servidor EP","component.memory_unavailable":"Memoria no disponible","component.owned_process_unavailable":"No hay proceso LaunchAgent activo","component.process_status":"Estado del proceso","component.runs_in_ep_server":"Se ejecuta en el servidor EP (PID {pid})","component.runs_in_ep_server_unavailable":"Se ejecuta en el servidor EP; estado no disponible","component.storage_no_process":"Componente de almacenamiento; sin proceso"}); Object.assign(DASHBOARD_MESSAGES.en, {"log_event.provider_action_requested":"Dashboard provider action requested","log_event.provider_action_started":"Dashboard provider login started","log_event.provider_action_completed":"Dashboard provider action completed","log_event.provider_action_failed":"Dashboard provider action failed"}); Object.assign(DASHBOARD_MESSAGES.nl, {"log_event.provider_action_requested":"Dashboard-provideractie aangevraagd","log_event.provider_action_started":"Dashboard-providerlogin gestart","log_event.provider_action_completed":"Dashboard-provideractie voltooid","log_event.provider_action_failed":"Dashboard-provideractie mislukt"}); Object.assign(DASHBOARD_MESSAGES.de, {"log_event.provider_action_requested":"Dashboard-Anbieteraktion angefordert","log_event.provider_action_started":"Dashboard-Anbieteranmeldung gestartet","log_event.provider_action_completed":"Dashboard-Anbieteraktion abgeschlossen","log_event.provider_action_failed":"Dashboard-Anbieteraktion fehlgeschlagen"}); diff --git a/src/engineering_platform/server.py b/src/engineering_platform/server.py index d8823cac..7f02e222 100644 --- a/src/engineering_platform/server.py +++ b/src/engineering_platform/server.py @@ -1273,11 +1273,14 @@ def _dashboard_relay_component(*, server_running: bool) -> dict[str, object]: definition = PLATFORM_COMPONENT_BY_ID["dashboard_relay"] label = definition.lifecycle_label try: - runtime = LaunchdProvider().runtime_status(label) if label else None + lifecycle = LaunchdProvider() + runtime = lifecycle.runtime_status(label) if label else None + observed = lifecycle.runtime_details(label) if label else None + observed = observed if isinstance(observed, LaunchdRuntimeDetails) else None relay_running = bool(runtime and runtime.qualified) detail = runtime.detail if runtime is not None else "lifecycle owner unavailable" except OSError: - relay_running, detail = False, "lifecycle owner unavailable" + observed, relay_running, detail = None, False, "lifecycle owner unavailable" healthy = server_running and relay_running return { "healthy": healthy, @@ -1285,7 +1288,9 @@ def _dashboard_relay_component(*, server_running: bool) -> dict[str, object]: "detail_code": definition.detail_code, "lifecycle_label": label, "lifecycle_state": "RUNNING" if relay_running else "STOPPED", - "uptime_seconds": 0, + # A Relay has its own LaunchAgent process. Never substitute a + # placeholder (notably zero) for the observed process lifetime. + "uptime_seconds": observed.uptime_seconds if observed is not None else None, "recent_error": None if healthy else detail, } @@ -1381,6 +1386,7 @@ def _platform_component_detail(data_root: Path, component_id: str) -> dict[str, detail["process_host"] = { "component": "ep_server", "pid": host.pid if host is not None and host.active else None, + "uptime_seconds": host.uptime_seconds if host is not None and host.active else None, } return detail diff --git a/tests/engineering/dashboard.spec.mjs b/tests/engineering/dashboard.spec.mjs index d5d6de93..fadab436 100644 --- a/tests/engineering/dashboard.spec.mjs +++ b/tests/engineering/dashboard.spec.mjs @@ -7801,6 +7801,7 @@ test.describe("Engineering Status browser smoke", () => { detail: "HTTP-dashboard reageert", version: "1.2.87", uptime_seconds: detailsRequest === 1 ? 10 : 20, + process_state: "OWNED_PROCESS", processes: [{ pid: 42, memory_kib: detailsRequest === 1 ? 1024 : 2048 }], launchd: {}, restart_supported: true, @@ -7851,9 +7852,10 @@ test.describe("Engineering Status browser smoke", () => { component: "lifecycle_worker", healthy: true, process_state: "IN_PROCESS", - process_host: { component: "ep_server", pid: 321 }, + process_host: { component: "ep_server", pid: 321, uptime_seconds: 3725 }, })); await expect(page.locator("#componentModalContent")).toContainText("ProcesstatusDraait in EP-server (PID 321)"); + await expect(page.locator("#componentModalContent")).toContainText("Gedeelde EP-server-uptime1u 2m"); await page.locator("#componentModalClose").click(); await page.evaluate(() => showComponentModal({ @@ -7864,6 +7866,17 @@ test.describe("Engineering Status browser smoke", () => { })); await expect(page.locator("#componentModalContent")).toContainText("ProcesstatusOpslagcomponent; geen proces"); await expect(page.locator("#componentModalContent")).toContainText("Databasegrootte12,5 MB"); + + await page.locator("#componentModalClose").click(); + await page.evaluate(() => showComponentModal({ + component: "dashboard_relay", + healthy: true, + uptime_seconds: 30, + process_state: "OWNED_PROCESS", + processes: [{ pid: 654, memory_kib: 1024 }], + })); + await expect(page.locator("#componentModalContent")).toContainText("Uptime30s"); + await expect(page.locator("#componentModalContent")).toContainText("Huidig geheugenPID 654: 1.0 MiB"); }); test("keeps the component-modal header inside its panel while details scroll", async ({ page }) => { diff --git a/tests/engineering/test_server_foundation.py b/tests/engineering/test_server_foundation.py index 47f0c947..b4147c86 100644 --- a/tests/engineering/test_server_foundation.py +++ b/tests/engineering/test_server_foundation.py @@ -78,11 +78,16 @@ def test_component_details_expose_only_the_relevant_installed_locations(self) -> @patch("engineering_platform.server.LaunchdProvider") def test_service_components_expose_their_launchagent_as_host_detail(self, launchd: object) -> None: server.initialize(self.root) - launchd.return_value.runtime_details.side_effect = ( - LaunchdRuntimeDetails("com.engineeringplatform.server", True, True, 321, "(never exited)", 2048, 61), - LaunchdRuntimeDetails("com.engineeringplatform.dashboard-relay", True, True, 654, "1", 1024, 30), - LaunchdRuntimeDetails("com.engineeringplatform.server", True, True, 321, "(never exited)", 2048, 61), - ) + def runtime_details(label: str) -> LaunchdRuntimeDetails: + return { + "com.engineeringplatform.server": LaunchdRuntimeDetails( + "com.engineeringplatform.server", True, True, 321, "(never exited)", 2048, 61, + ), + "com.engineeringplatform.dashboard-relay": LaunchdRuntimeDetails( + "com.engineeringplatform.dashboard-relay", True, True, 654, "1", 1024, 30, + ), + }[label] + launchd.return_value.runtime_details.side_effect = runtime_details with patch("engineering_platform.server._launch_agent_configuration", return_value={ "run_at_load": True, "keep_alive": True, @@ -102,7 +107,9 @@ def test_service_components_expose_their_launchagent_as_host_detail(self, launch self.assertEqual(ep_server["uptime_seconds"], 61) self.assertEqual(relay["processes"], [{"pid": 654, "memory_kib": 1024}]) self.assertEqual(worker["process_state"], "IN_PROCESS") - self.assertEqual(worker["process_host"], {"component": "ep_server", "pid": 321}) + self.assertEqual(worker["process_host"], { + "component": "ep_server", "pid": 321, "uptime_seconds": 61, + }) def test_launchagent_configuration_projects_boolean_and_dictionary_policies(self) -> None: plist_path = self.root / "agent.plist" @@ -559,10 +566,14 @@ def test_dashboard_relay_requires_the_real_launch_agent_and_server(self) -> None launchd.return_value.runtime_status.return_value = ProviderStatus( "launchd", "configured", True, "LaunchAgent process is active" ) + launchd.return_value.runtime_details.return_value = LaunchdRuntimeDetails( + "com.engineeringplatform.dashboard-relay", True, True, 654, None, 1024, 30, + ) component = server.status(self.root)["components"]["dashboard_relay"] self.assertTrue(component["healthy"]) self.assertEqual(component["status_code"], "DASHBOARD_RELAY_ACTIVE") self.assertEqual(component["lifecycle_state"], "RUNNING") + self.assertEqual(component["uptime_seconds"], 30) with patch("engineering_platform.server._runtime", return_value={"pid": 73}), patch( "engineering_platform.server._alive", return_value=True