From 16048e2ce3f736543b722a75f09d0e4674bafe89 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Sun, 16 Aug 2026 09:08:03 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20merge=20develop=20into=20mast?= =?UTF-8?q?er=20=E2=80=94=20XL=20Vask=20flag=20text=20fix=20+=20edge-broke?= =?UTF-8?q?r=20health=20(Aug=2015=202026)=20(#376)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings all of the develop branch's commits into master. ## What this contains The 2 commits on develop that landed during the XL Vask integration dispatch: - **PR #373** (TRU-6 / AUT-2) — feat(edge-broker): expose lastActivityAt on /api/health (AUT-2/TRU-6) - **PR #375** (TRU-49 / AUT-49) — fix(api): include wash_id in xlvask_missing_order_link flag text (AUT-49/TRU-49) ## Why The XL Vask integration dispatch via the OpenSymphony orchestrator (MiniMax M3) produced 2 api-side fixes: - **PR #373** — adds `lastActivityAt` to the api health endpoint so operators can see if the edge-broker has processed any requests recently. - **PR #375** — the actual root-cause fix for the user-reported symptom "XL Vask-registreringen er hverken ignoreret eller knyttet til en ordre i den valgte periode doesn't show the wash". The bug was in `messageParts()` for the `xlvask_missing_order_link` arm — the link text was hard-coded to 'XL Vask wash' instead of using the actual wash_id. This PR makes the link identify the wash it points to. ## Verification Both source PRs passed: - Required CI (PHP unit, PHP integration, PHP api, PHP legacy, edge broker, edge agent, edge gateway backend) - The api ruleset allows squash merges ## Notes - The pleno-vue repo has its own equivalent develop→master PR (#312) with the 9 UI fixes (component, i18n, and a Playwright E2E). --------- Co-authored-by: Jeppe Co-authored-by: openhands --- documentation/topics/API-Reference.topic | 1 + scripts/generate_writerside_openapi_docs.py | 4 ++ services/edge-broker/server.mjs | 8 ++++ services/edge-broker/test/broker.test.mjs | 39 +++++++++++++++++++ .../classes/invoice_period_flag_service.php | 8 +++- .../InvoicePeriodFlagServiceTest.php | 3 +- 6 files changed, 60 insertions(+), 3 deletions(-) diff --git a/documentation/topics/API-Reference.topic b/documentation/topics/API-Reference.topic index 031d8423..3c731b0b 100644 --- a/documentation/topics/API-Reference.topic +++ b/documentation/topics/API-Reference.topic @@ -7,4 +7,5 @@

Comprehensive API reference generated from the repository root openapi.yaml.

+

The edge broker's /api/health response additionally exposes a lastActivityAt field (ISO 8601 timestamp). It reports the most recent successful HTTP request handled by the broker container and defaults to the container's start time when no request has been processed yet.

diff --git a/scripts/generate_writerside_openapi_docs.py b/scripts/generate_writerside_openapi_docs.py index 01795bbf..3dbca438 100644 --- a/scripts/generate_writerside_openapi_docs.py +++ b/scripts/generate_writerside_openapi_docs.py @@ -405,6 +405,10 @@ def render_api_reference_topic() -> str: ' title="API Reference" id="API-Reference">\n' f"\n \n" "

Comprehensive API reference generated from the repository root openapi.yaml.

\n" + "

The edge broker's /api/health response additionally exposes a " + "lastActivityAt field (ISO 8601 timestamp). It reports the most recent " + "successful HTTP request handled by the broker container and defaults to the container's " + "start time when no request has been processed yet.

\n" "\n" ) diff --git a/services/edge-broker/server.mjs b/services/edge-broker/server.mjs index eb9a2712..25331dc3 100644 --- a/services/edge-broker/server.mjs +++ b/services/edge-broker/server.mjs @@ -189,6 +189,8 @@ export function createBrokerServer(options = {}) { const browserStreamSessions = new Map(); const gatewayStreamSessions = new Map(); const inflightGatewaySyncs = new Map(); + const containerStartedAt = currentTimestamp(); + let lastActivityAt = containerStartedAt; const managerRequest = async (path, body = {}, method = "POST") => { if (!managerUrl) { @@ -480,6 +482,7 @@ export function createBrokerServer(options = {}) { const server = http.createServer(async (req, res) => { try { const url = new URL(req.url, "http://localhost"); + lastActivityAt = currentTimestamp(); if (req.method === "GET" && url.pathname === "/api/health") { jsonResponse(res, 200, { ok: true, @@ -488,6 +491,7 @@ export function createBrokerServer(options = {}) { manager_url_configured: Boolean(managerUrl), shared_secret_configured: Boolean(sharedSecret), agents_connected: agents.size, + lastActivityAt, }); return; } @@ -1083,6 +1087,10 @@ export function createBrokerServer(options = {}) { pendingCommands, managerUrl, authMode, + containerStartedAt, + get lastActivityAt() { + return lastActivityAt; + }, }, }; } diff --git a/services/edge-broker/test/broker.test.mjs b/services/edge-broker/test/broker.test.mjs index 7be7368f..f2471a09 100644 --- a/services/edge-broker/test/broker.test.mjs +++ b/services/edge-broker/test/broker.test.mjs @@ -219,6 +219,10 @@ test("broker exposes health and shared-secret diagnostics", async () => { assert.equal(healthJson.auth_mode, "manager"); assert.equal(healthJson.manager_url_configured, true); assert.equal(healthJson.shared_secret_configured, true); + assert.equal(typeof healthJson.lastActivityAt, "string"); + assert.match(healthJson.lastActivityAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + assert.ok(healthJson.lastActivityAt >= broker.state.containerStartedAt); + assert.equal(healthJson.lastActivityAt, broker.state.lastActivityAt); const invalidSecretResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, { method: "POST", @@ -247,6 +251,41 @@ test("broker exposes health and shared-secret diagnostics", async () => { await broker.close(); }); +test("broker updates lastActivityAt after each successful request", async () => { + const broker = createBrokerServer({ authMode: "manager", sharedSecret: "secret", managerUrl: "http://manager.test" }); + const address = await broker.listen(0); + const port = address.port; + + assert.equal(broker.state.lastActivityAt, broker.state.containerStartedAt); + + const firstResponse = await fetch(`http://127.0.0.1:${port}/api/health`); + const firstJson = await firstResponse.json(); + const firstActivityAt = broker.state.lastActivityAt; + + assert.equal(typeof firstJson.lastActivityAt, "string"); + assert.equal(firstJson.lastActivityAt, firstActivityAt); + assert.ok(firstActivityAt >= broker.state.containerStartedAt); + + await new Promise((resolve) => setTimeout(resolve, 5)); + + await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, { + method: "POST", + headers: { + "x-edge-broker-secret": "secret", + }, + }); + + assert.notEqual(broker.state.lastActivityAt, firstActivityAt); + assert.ok(broker.state.lastActivityAt > firstActivityAt); + + const secondResponse = await fetch(`http://127.0.0.1:${port}/api/health`); + const secondJson = await secondResponse.json(); + + assert.equal(secondJson.lastActivityAt, broker.state.lastActivityAt); + + await broker.close(); +}); + test("broker bridges browser shell sessions through the connected agent", async () => { const closedSessions = []; const broker = createBrokerServer({ diff --git a/services/nginx/app/classes/invoice_period_flag_service.php b/services/nginx/app/classes/invoice_period_flag_service.php index 2b1f823f..2646ee4b 100644 --- a/services/nginx/app/classes/invoice_period_flag_service.php +++ b/services/nginx/app/classes/invoice_period_flag_service.php @@ -1495,6 +1495,7 @@ class invoice_period_flag_service { $product = (string)($params['product'] ?? 'Item'); $expectedProduct = (string)($params['expected_product'] ?? 'expected product'); + $washId = (string)($params['wash_id'] ?? ''); return match ($definitionKey) { 'price_mismatch' => "{$product} product price differs from expected.", 'customer_rule_restrict_addon_services' => "{$product} violates restricted addon services.", @@ -1514,7 +1515,9 @@ class invoice_period_flag_service 'duplicate_vehicle_subscription_charge_same_month' => "Duplicate vehicle subscription charges exist in the same month.", 'vehicle_subscription_type_mismatch' => "{$product} does not match the vehicle subscription type {$expectedProduct}.", 'historical_primary_product_mismatch' => "{$product} differs from the registration number's usual product {$expectedProduct}.", - 'xlvask_missing_order_link' => "XL Vask wash is neither ignored nor linked to an order in the selected period.", + 'xlvask_missing_order_link' => $washId === '' + ? 'XL Vask wash is neither ignored nor linked to an order in the selected period.' + : "XL Vask wash {$washId} is neither ignored nor linked to an order in the selected period.", default => "Automatically detected invoice-period issue.", }; } @@ -1541,7 +1544,8 @@ class invoice_period_flag_service ['type' => 'text', 'text' => ' is attached without a wash certificate item.'], ], 'xlvask_missing_order_link' => [ - ['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'], + ['type' => 'text', 'text' => 'XL Vask wash '], + ['type' => 'xlvask_usage_log', 'text' => (string)($params['wash_id'] ?? '')], ['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'], ], default => [], diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php index f9d1be45..d2449e66 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php @@ -167,7 +167,8 @@ it('builds interactive message parts for order and wash certificate warnings', f ['type' => 'text', 'text' => ' is present without a wash certificate.'], ]); expect($xlVaskFlag['message_parts'])->toBe([ - ['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'], + ['type' => 'text', 'text' => 'XL Vask wash '], + ['type' => 'xlvask_usage_log', 'text' => 'wash-55'], ['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'], ]); });