mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
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:
1
changelog.d/fixes/12857-empty-quota-unknown.md
Normal file
1
changelog.d/fixes/12857-empty-quota-unknown.md
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
}>;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<{
|
||||
|
||||
24
tests/unit/api-health-version-source.test.ts
Normal file
24
tests/unit/api-health-version-source.test.ts
Normal 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");
|
||||
});
|
||||
63
tests/unit/combo-health-empty-snapshot.test.ts
Normal file
63
tests/unit/combo-health-empty-snapshot.test.ts
Normal 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);
|
||||
});
|
||||
77
tests/unit/ui/combo-health-null-quota.test.tsx
Normal file
77
tests/unit/ui/combo-health-null-quota.test.tsx
Normal 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%");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user