From 85d29b253ffbc5e423a0acd55dbb0e25218d4c61 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:17:58 +0200 Subject: [PATCH] fix(health): read empty quota as unknown instead of 0% (#12857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Um 0% vermelho num install novo não é só feio: é um número contraditório, porque afirma medição onde não houve nenhuma. Ler ausência como "n/a" é a correção certa, e mantê-la display-only no caminho de combo health mantém o escopo honesto. Registro que gostei: a PR **não** mexe no `/api/health`, e diz por quê — qualquer coisa que aquela rota devolva é pública numa instância exposta. Recusar o escopo adjacente com a razão escrita vale mais que a mudança em si. Revalidei após mergear a base na branch: `api-health-version-source` + `combo-health-empty-snapshot` 5/5 no runner Node, `combo-health-null-quota` 1/1 no vitest, typecheck:core limpo. **Integração:** `src/app/api/system/version/route.ts` conflitou com o `restartRunningServer` que entrou na release depois que você cortou a branch. Aditivo — ficaram os dois imports, a sua troca por `APP_CONFIG.version` e o passo de restart do outro PR. --- .../fixes/12857-empty-quota-unknown.md | 1 + .../dashboard/analytics/ComboHealthTab.tsx | 7 +- src/app/api/health/route.ts | 1 + src/app/api/system/version/route.ts | 7 +- src/lib/combos/controlCenter.ts | 4 +- src/lib/usage/comboHealth.ts | 32 ++++---- src/shared/types/utilization.ts | 4 +- tests/unit/api-health-version-source.test.ts | 24 ++++++ .../unit/combo-health-empty-snapshot.test.ts | 63 +++++++++++++++ .../unit/ui/combo-health-null-quota.test.tsx | 77 +++++++++++++++++++ 10 files changed, 191 insertions(+), 29 deletions(-) create mode 100644 changelog.d/fixes/12857-empty-quota-unknown.md create mode 100644 tests/unit/api-health-version-source.test.ts create mode 100644 tests/unit/combo-health-empty-snapshot.test.ts create mode 100644 tests/unit/ui/combo-health-null-quota.test.tsx diff --git a/changelog.d/fixes/12857-empty-quota-unknown.md b/changelog.d/fixes/12857-empty-quota-unknown.md new file mode 100644 index 0000000000..4cbace07ff --- /dev/null +++ b/changelog.d/fixes/12857-empty-quota-unknown.md @@ -0,0 +1 @@ +- **fix(health):** quota with no snapshots reads empty instead of a contradicting 0% ([#12857](https://github.com/diegosouzapw/OmniRoute/pull/12857)) — thanks @maxmad64bis diff --git a/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx b/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx index d36ea43170..c0e93258a3 100644 --- a/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx +++ b/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx @@ -526,7 +526,7 @@ function ComboHealthCard({ {combo.quotaHealth.providers.map((provider) => { const trendMeta = getTrendMeta(provider.trend); - const width = `${Math.max(provider.remainingPct, provider.remainingPct > 0 ? 6 : 0)}%`; + const pct = provider.remainingPct; + const width = `${pct === null ? 0 : Math.max(pct, pct > 0 ? 6 : 0)}%`; return (
{t("comboHealthRemainingQuota", { - value: formatPercent(provider.remainingPct, 1), + value: formatPercentOrDash(provider.remainingPct, 1), })}
diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 2ead593b78..860e9f03f9 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -14,6 +14,7 @@ import { NextResponse } from "next/server"; * public on an exposed instance, so version, uptime and memory stay behind the authenticated * `/api/monitoring/health`. For a probe that also confirms the database answers, use * `/api/health/ping`. + * Readiness (DB-backed) lives at /api/health/ping (pingDb, 503 when down); this route stays liveness-only so a slow DB never restarts the container. */ export const dynamic = "force-dynamic"; diff --git a/src/app/api/system/version/route.ts b/src/app/api/system/version/route.ts index f3ec9c26d4..5284d00077 100644 --- a/src/app/api/system/version/route.ts +++ b/src/app/api/system/version/route.ts @@ -25,6 +25,7 @@ import { } from "@/lib/system/versionCheck"; import { resolveGlobalOmniroutePath } from "@/lib/system/globalPackagePath"; import { restartRunningServer } from "@/lib/system/processManagerRestart"; +import { APP_CONFIG } from "@/shared/constants/appConfig"; // #5542 — On Windows npm is `npm.cmd`; Node ≥24 refuses to execFile a `.cmd` without // a shell (nodejs/node#52554 → "spawn npm ENOENT"). buildNpmExecOptions enables the // shell on win32 only; SERVICE_VERSION_PATTERN keeps the shell-joined version safe. @@ -35,11 +36,7 @@ const execFileAsync = promisify(execFile); export const dynamic = "force-dynamic"; function getCurrentVersion(): string { - try { - return require("../../../../../package.json").version as string; - } catch { - return "unknown"; - } + return APP_CONFIG.version; } /** diff --git a/src/lib/combos/controlCenter.ts b/src/lib/combos/controlCenter.ts index 825f96942c..d606d2ec93 100644 --- a/src/lib/combos/controlCenter.ts +++ b/src/lib/combos/controlCenter.ts @@ -31,10 +31,10 @@ export interface ComboControlCenterHealth { totalRequests?: number; }; quotaHealth?: { - worstRemainingPct?: number; + worstRemainingPct?: number | null; providers?: Array<{ provider: string; - remainingPct: number; + remainingPct: number | null; isExhausted: boolean; trend: "improving" | "stable" | "declining"; }>; diff --git a/src/lib/usage/comboHealth.ts b/src/lib/usage/comboHealth.ts index a9f12ac1f2..2bb72b5e95 100644 --- a/src/lib/usage/comboHealth.ts +++ b/src/lib/usage/comboHealth.ts @@ -32,7 +32,7 @@ type QuotaSnapshotView = { type ProviderHealth = { provider: string; - remainingPct: number; + remainingPct: number | null; isExhausted: boolean; trend: "improving" | "stable" | "declining"; }; @@ -112,11 +112,12 @@ function calculateGini(values: number[]): number { return (2 * weightedSum) / (count * sum) - (count + 1) / count; } -function buildProviderHealth(provider: string, snapshots: QuotaSnapshotRow[]): ProviderHealth { +export function buildProviderHealth(provider: string, snapshots: QuotaSnapshotRow[]): ProviderHealth { if (snapshots.length === 0) { return { provider, - remainingPct: 0, + remainingPct: null, + // stable: no data yet, not exhausted — null pct, not 0 isExhausted: false, trend: "stable", }; @@ -186,7 +187,7 @@ function buildProviderHealth(provider: string, snapshots: QuotaSnapshotRow[]): P return { provider, - remainingPct: roundNumber(lastAverage), + remainingPct: lastValues.length === 0 ? null : roundNumber(lastAverage), isExhausted, trend, }; @@ -218,10 +219,10 @@ function buildConnectionHealth( }); const firstRemaining = - (firstSnapshot as unknown as QuotaSnapshotView | undefined)?.remainingPercentage ?? 0; + (firstSnapshot as unknown as QuotaSnapshotView | undefined)?.remainingPercentage ?? null; const lastRemaining = - (lastSnapshot as unknown as QuotaSnapshotView | undefined)?.remainingPercentage ?? 0; - const delta = lastRemaining - firstRemaining; + (lastSnapshot as unknown as QuotaSnapshotView | undefined)?.remainingPercentage ?? null; + const delta = (lastRemaining ?? 0) - (firstRemaining ?? 0); let trend: ProviderHealth["trend"] = "stable"; if (delta >= 5) trend = "improving"; @@ -229,7 +230,7 @@ function buildConnectionHealth( return { provider: `${provider}:${connectionId}`, - remainingPct: roundNumber(lastRemaining), + remainingPct: lastRemaining === null ? null : roundNumber(lastRemaining), isExhausted: (ordered[ordered.length - 1] as unknown as QuotaSnapshotView | undefined)?.isExhausted === 1, trend, @@ -317,22 +318,19 @@ function buildPerformance(comboName: string, since: string): ComboHealthMetrics[ }; } -function buildQuotaHealth(providers: string[], since: string): ComboHealthMetrics["quotaHealth"] { +export function buildQuotaHealth(providers: string[], since: string): ComboHealthMetrics["quotaHealth"] { const providerHealth = providers.map((provider) => buildProviderHealth(provider, getQuotaSnapshots({ provider, since })) ); - const worstRemainingPct = - providerHealth.length > 0 - ? providerHealth.reduce( - (lowest, entry) => Math.min(lowest, entry.remainingPct), - providerHealth[0].remainingPct - ) - : 0; + const nonNull = providerHealth + .map((entry) => entry.remainingPct) + .filter((v): v is number => typeof v === "number"); + const worst = nonNull.length > 0 ? Math.min(...nonNull) : null; return { providers: providerHealth, - worstRemainingPct: roundNumber(worstRemainingPct), + worstRemainingPct: worst === null ? null : roundNumber(worst), }; } diff --git a/src/shared/types/utilization.ts b/src/shared/types/utilization.ts index 90fb14fce0..ba519ed9fe 100644 --- a/src/shared/types/utilization.ts +++ b/src/shared/types/utilization.ts @@ -58,11 +58,11 @@ export interface ComboHealthMetrics { quotaHealth: { providers: Array<{ provider: string; - remainingPct: number; + remainingPct: number | null; isExhausted: boolean; trend: "improving" | "stable" | "declining"; }>; - worstRemainingPct: number; + worstRemainingPct: number | null; }; usageSkew: { modelDistribution: Array<{ diff --git a/tests/unit/api-health-version-source.test.ts b/tests/unit/api-health-version-source.test.ts new file mode 100644 index 0000000000..24d32d2be6 --- /dev/null +++ b/tests/unit/api-health-version-source.test.ts @@ -0,0 +1,24 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { GET } from "@/app/api/health/route"; +import { APP_CONFIG } from "@/shared/constants/appConfig"; + +test("GET /api/health stays minimal: 200, no version anywhere", async () => { + const res = await GET(); + assert.equal(res.status, 200); + assert.equal(res.headers.get("ETag"), null); + assert.equal(res.headers.get("X-OmniRoute-Version"), null); + const body = (await res.json()) as Record; + assert.equal(body.status, "ok"); + assert.ok(typeof body.timestamp === "string"); + assert.ok(!("version" in body)); +}); + +test("system/version reads no package.json directly", async () => { + const { readFileSync } = await import("node:fs"); + const src = readFileSync(path.join(process.cwd(), "src/app/api/system/version/route.ts"), "utf8"); + assert.ok(!src.includes("require("), "direct require(package.json) must be gone"); + assert.ok(src.includes("APP_CONFIG.version")); + assert.equal(typeof APP_CONFIG.version, "string"); +}); diff --git a/tests/unit/combo-health-empty-snapshot.test.ts b/tests/unit/combo-health-empty-snapshot.test.ts new file mode 100644 index 0000000000..25ff864935 --- /dev/null +++ b/tests/unit/combo-health-empty-snapshot.test.ts @@ -0,0 +1,63 @@ +// tests/unit/combo-health-empty-snapshot.test.ts — pattern db-quota-snapshots.test.ts:7-26 : +// isolation DB réelle, zéro mock (mock.module indisponible sous tsx/ESM ; sans polyfill+isolateDataDir, +// DATA_DIR tombe sur ~/.omniroute réel → flaky). +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-combo-health-null-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const coreDb = await import("../../src/lib/db/core.ts"); +const quotaSnapshotsDb = await import("../../src/lib/db/quotaSnapshots.ts"); +const { buildProviderHealth, buildQuotaHealth } = + await import("../../src/lib/usage/comboHealth.ts"); + +async function resetStorage() { + coreDb.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + coreDb.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +const SNAP = { + provider: "openrouter", + connection_id: "conn-1", + window_key: "hourly", + is_exhausted: 0, + next_reset_at: "2026-01-01T01:00:00.000Z", + window_duration_ms: 3600000, + raw_data: "{}", +} as const; + +test("empty snapshots read null, not 0%, and stay non-exhausted", () => { + const h = buildProviderHealth("openrouter", []); + assert.equal(h.remainingPct, null); + assert.equal(h.isExhausted, false); + assert.equal(h.trend, "stable"); +}); + +test("all-null percentages read null (B3)", () => { + quotaSnapshotsDb.saveQuotaSnapshot({ ...SNAP, remaining_percentage: null }); + quotaSnapshotsDb.saveQuotaSnapshot({ ...SNAP, remaining_percentage: null }); + const q = buildQuotaHealth(["openrouter"], "1970-01-01T00:00:00.000Z"); + assert.equal(q.providers[0].remainingPct, null); + assert.equal(q.worstRemainingPct, null); +}); + +test("worstRemainingPct ignores nulls, null when all null", () => { + quotaSnapshotsDb.saveQuotaSnapshot({ ...SNAP, remaining_percentage: null }); + quotaSnapshotsDb.saveQuotaSnapshot({ ...SNAP, remaining_percentage: 42.346 }); + const q = buildQuotaHealth(["openrouter"], "1970-01-01T00:00:00.000Z"); + assert.equal(q.worstRemainingPct, 42.35); +}); diff --git a/tests/unit/ui/combo-health-null-quota.test.tsx b/tests/unit/ui/combo-health-null-quota.test.tsx new file mode 100644 index 0000000000..4b781083a4 --- /dev/null +++ b/tests/unit/ui/combo-health-null-quota.test.tsx @@ -0,0 +1,77 @@ +// @vitest-environment jsdom +// ComboHealthTab fetch "/api/usage/combo-health-dashboard?range=${range}&horizon=${horizon}" (ComboHealthTab.tsx:817-820) au mount ; +// on intercepte global.fetch et on monte le composant réel. +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const translate = (key: string, params?: Record) => + params && "value" in params ? String(params.value) : key; +vi.mock("next-intl", () => ({ useTranslations: () => translate })); + +const ComboHealthTab = ( + await import("../../../src/app/(dashboard)/dashboard/analytics/ComboHealthTab") +).default; + +const NULL_COMBO = { + comboId: "c1", + comboName: "null-quota", + strategy: "auto", + models: ["m1"], + quotaHealth: { + providers: [ + { provider: "openrouter", remainingPct: null, isExhausted: false, trend: "stable" }, + ], + worstRemainingPct: null, + }, + usageSkew: { modelDistribution: [], giniCoefficient: 0 }, + performance: { avgLatencyMs: 0, successRate: 0, totalRequests: 0 }, +}; +// Enveloppe réelle lue par ComboHealthTab.tsx:828-836 : result.health + result.errors +// (pas {combos:[…]} — sinon setData(undefined), liste vide, jamais de n/a). +const NULL_PAYLOAD = { + health: { timeRange: "24h", combos: [NULL_COMBO] }, + forecast: null, + autopilot: null, + scoring: null, + errors: {}, +}; + +function mount() { + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + return { el, root }; +} + +describe("combo health null quota", () => { + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(async (url: unknown) => { + expect(String(url)).toContain("/api/usage/combo-health-dashboard"); + return new Response(JSON.stringify(NULL_PAYLOAD), { status: 200 }); + }) + ); + }); + afterEach(() => { + vi.unstubAllGlobals(); + document.body.innerHTML = ""; + }); + + it("renders n/a in the quota section, bar width 0%", async () => { + const { el, root } = mount(); + await act(async () => { + root.render(); + }); + // Assertion scopée à la section quota (C1) : le bloc perf rend "0.0%" légitime + // via formatPercent(successRate*100) même post-fix — un not.toContain("0%") global + // serait un faux-positif permanent. + const quotaSection = el.querySelector("section") as HTMLElement | null; + const quotaText = quotaSection?.textContent ?? ""; + expect(quotaText).toContain("n/a"); + const bar = quotaSection?.querySelector('[style*="width"]') as HTMLElement | null; + expect(bar?.style.width).toBe("0%"); + }); +});