fix(health): read empty quota as unknown instead of 0% (#12857)

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.
This commit is contained in:
Dizzle
2026-09-10 13:17:58 +02:00
committed by GitHub
parent bfbd090a96
commit 85d29b253f
10 changed files with 191 additions and 29 deletions

View File

@@ -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

View File

@@ -526,7 +526,7 @@ function ComboHealthCard({
<MetricBlock
icon="battery_status_good"
label={t("comboHealthWorstQuotaLeft")}
value={formatPercent(combo.quotaHealth.worstRemainingPct)}
value={formatPercentOrDash(combo.quotaHealth.worstRemainingPct)}
/>
<MetricBlock
icon="balance"
@@ -560,7 +560,8 @@ function ComboHealthCard({
<div className="flex flex-col gap-3">
{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 (
<div
@@ -574,7 +575,7 @@ function ComboHealthCard({
</div>
<div className="mt-1 text-xs text-text-muted">
{t("comboHealthRemainingQuota", {
value: formatPercent(provider.remainingPct, 1),
value: formatPercentOrDash(provider.remainingPct, 1),
})}
</div>
</div>

View File

@@ -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";

View File

@@ -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;
}
/**

View File

@@ -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";
}>;

View File

@@ -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),
};
}

View File

@@ -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<{

View File

@@ -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<string, unknown>;
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");
});

View File

@@ -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);
});

View File

@@ -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<string, unknown>) =>
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(<ComboHealthTab />);
});
// 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%");
});
});