From ec0381c453f0c098dafb826f54f43fb89f8cc521 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:06:00 -0300 Subject: [PATCH] fix(api): point CLI health command at /api/monitoring/health (#6677) (#6717) * fix(api): point CLI health command at /api/monitoring/health (#6677) bin/cli/commands/health.mjs called GET /api/health, a route that was moved to /api/monitoring/health without updating the CLI; the top-level /api/health handler never existed on disk (only degradation/ and ping/ sub-routes). Point runHealthCommand()/runHealthComponentsCommand() at /api/monitoring/health and read its real payload shape (activeConnections, circuitBreakers: {open,halfOpen,closed}, memoryUsage) instead of the old nonexistent requests/breakers/cache/memory fields. * chore(6717): re-sync onto release tip; move CHANGELOG entry to changelog.d fragment (fragments-first) --- bin/cli/commands/health.mjs | 41 +++++----- .../fixes/6717-cli-health-monitoring-route.md | 1 + .../unit/cli-health-monitoring-route.test.ts | 76 +++++++++++++++++++ 3 files changed, 98 insertions(+), 20 deletions(-) create mode 100644 changelog.d/fixes/6717-cli-health-monitoring-route.md create mode 100644 tests/unit/cli-health-monitoring-route.test.ts diff --git a/bin/cli/commands/health.mjs b/bin/cli/commands/health.mjs index f9289e170a..d61a3e9c39 100644 --- a/bin/cli/commands/health.mjs +++ b/bin/cli/commands/health.mjs @@ -48,7 +48,11 @@ export async function runHealthCommand(opts = {}) { } try { - const res = await apiFetch("/api/health", { retry: false, timeout: 5000, acceptNotOk: true }); + const res = await apiFetch("/api/monitoring/health", { + retry: false, + timeout: 5000, + acceptNotOk: true, + }); if (!res.ok) { console.error(t("common.error", { message: `HTTP ${res.status}` })); return 1; @@ -66,29 +70,22 @@ export async function runHealthCommand(opts = {}) { if (health.uptime) console.log(t("health.uptime", { uptime: health.uptime })); if (health.version) console.log(` Version: ${health.version}`); - if (health.requests !== undefined) { - console.log(t("health.requests", { count: health.requests })); + if (health.activeConnections !== undefined) { + console.log(t("health.requests", { count: health.activeConnections })); } - if (health.breakers && opts.verbose) { + if (health.circuitBreakers && opts.verbose) { console.log("\n \x1b[1mCircuit Breakers\x1b[0m"); - for (const [name, status] of Object.entries(health.breakers)) { - const state = - status.state === "closed" ? "\x1b[32m● closed\x1b[0m" : "\x1b[33m○ open\x1b[0m"; - console.log(` ${name.padEnd(20)} ${state}`); - } + const { open = 0, halfOpen = 0, closed = 0 } = health.circuitBreakers; + console.log(` \x1b[32m● closed\x1b[0m ${closed}`); + console.log(` \x1b[33m○ half-open\x1b[0m ${halfOpen}`); + console.log(` \x1b[31m○ open\x1b[0m ${open}`); } - if (health.cache && opts.verbose) { - console.log("\n \x1b[1mCache\x1b[0m"); - console.log(` Semantic hits: ${health.cache.semanticHits || 0}`); - console.log(` Signature hits: ${health.cache.signatureHits || 0}`); - } - - if (opts.verbose && health.memory) { + if (opts.verbose && health.memoryUsage) { console.log("\n \x1b[1mMemory\x1b[0m"); - console.log(` RSS: ${health.memory.rss || "N/A"}`); - console.log(` Heap used: ${health.memory.heapUsed || "N/A"}`); + console.log(` RSS: ${health.memoryUsage.rss || "N/A"}`); + console.log(` Heap used: ${health.memoryUsage.heapUsed || "N/A"}`); } return 0; @@ -100,13 +97,17 @@ export async function runHealthCommand(opts = {}) { export async function runHealthComponentsCommand(opts = {}) { try { - const res = await apiFetch("/api/health", { retry: false, timeout: 5000, acceptNotOk: true }); + const res = await apiFetch("/api/monitoring/health", { + retry: false, + timeout: 5000, + acceptNotOk: true, + }); if (!res.ok) { console.error(`HTTP ${res.status}`); return 1; } const health = await res.json(); - const components = health.components || health.breakers || {}; + const components = health.components || health.circuitBreakers || {}; for (const [name, info] of Object.entries(components)) { const status = typeof info === "object" ? info.state || info.status || "unknown" : String(info); diff --git a/changelog.d/fixes/6717-cli-health-monitoring-route.md b/changelog.d/fixes/6717-cli-health-monitoring-route.md new file mode 100644 index 0000000000..80e5ad7ec7 --- /dev/null +++ b/changelog.d/fixes/6717-cli-health-monitoring-route.md @@ -0,0 +1 @@ +- **fix(api):** `omniroute health` (and `health components`/`health watch`) returned `Error: HTTP 404` ([#6677](https://github.com/diegosouzapw/OmniRoute/issues/6677)) — `bin/cli/commands/health.mjs` called `apiFetch("/api/health", ...)`, a route that was moved to `GET /api/monitoring/health` (`src/app/api/monitoring/health/route.ts`) without updating the CLI; `src/app/api/health/` on disk only has `degradation/route.ts` and `ping/route.ts`, no top-level handler. `runHealthCommand()`/`runHealthComponentsCommand()` now call `/api/monitoring/health` and read its actual payload shape (`activeConnections`, `circuitBreakers: {open, halfOpen, closed}`, `memoryUsage`) instead of the old, nonexistent `requests`/`breakers`/`cache`/`memory` fields. Regression guard: `tests/unit/cli-health-monitoring-route.test.ts`. diff --git a/tests/unit/cli-health-monitoring-route.test.ts b/tests/unit/cli-health-monitoring-route.test.ts new file mode 100644 index 0000000000..f2168ba400 --- /dev/null +++ b/tests/unit/cli-health-monitoring-route.test.ts @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { runHealthCommand } from "../../bin/cli/commands/health.mjs"; + +// Regression test for GH #6677: `omniroute health` calls GET /api/health, but the +// real server only implements GET /api/monitoring/health (plus the sub-routes +// /api/health/degradation and /api/health/ping). This stub server mimics that +// exact real-world shape: /api/monitoring/health responds 200 with a healthy +// payload, everything else (including /api/health) 404s. +// +// Expectation once fixed: runHealthCommand() should hit /api/monitoring/health +// and return exit code 0. + +let server: http.Server; +let baseUrl: string; + +test.before(async () => { + server = http.createServer((req, res) => { + if (req.url === "/api/monitoring/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + status: "healthy", + version: "3.8.47", + uptime: 123, + activeConnections: 0, + circuitBreakers: { open: 0, halfOpen: 0, closed: 3 }, + memoryUsage: { rss: 1000, heapUsed: 500 }, + }) + ); + return; + } + // Everything else, including the legacy /api/health the CLI used to call, + // 404s — matching the real deployed route tree (only + // app/api/health/degradation and app/api/health/ping exist on disk). + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "Not Found" })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const address = server.address(); + if (address && typeof address === "object") { + baseUrl = `http://127.0.0.1:${address.port}`; + } + process.env.OMNIROUTE_BASE_URL = baseUrl; +}); + +test.after(async () => { + delete process.env.OMNIROUTE_BASE_URL; + await new Promise((resolve) => server.close(() => resolve())); +}); + +test("GH #6677: omniroute health should succeed against a server that only implements /api/monitoring/health", async () => { + const originalError = console.error; + const originalLog = console.log; + const errors: string[] = []; + console.error = (...args: unknown[]) => { + errors.push(args.map(String).join(" ")); + }; + console.log = () => {}; + + let exitCode: number; + try { + exitCode = await runHealthCommand({}); + } finally { + console.error = originalError; + console.log = originalLog; + } + + assert.equal( + exitCode, + 0, + `runHealthCommand() should return 0 against a live server that implements ` + + `/api/monitoring/health, but got exit code ${exitCode}. Captured stderr: ${errors.join(" | ")}` + ); +});