mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
* fix(dashboard): resolve broken Card import breaking next build (base-red from #6061) CoolingConnectionsPanel imported `Card` from `@/components/ui/card`, a path that does not exist in this repo (there is no shadcn-style `src/components/ui/`). The PR->release fast-gates do not run `next build`, so the broken import slipped in and `next build` failed with: Module not found: Can't resolve '@/components/ui/card' Fix: the <Card> here was only a styled container, so replace it with a <div> carrying the equivalent Tailwind classes (border/bg/padding + rounded-card shadow-sm). Also normalize the file from CRLF to LF (it shipped with CRLF). Adds a vitest/jsdom regression test (tests/unit/ui/CoolingConnectionsPanel.test.tsx) that fails-without-fix (Vite: 'Failed to resolve import @/components/ui/card') and passes with it, plus renders/empty-state coverage. Rule #18. * fix(dashboard): stop client CoolingConnectionsPanel dragging server DB barrel into browser bundle Second base-red from #6061, surfaced once the broken Card import was fixed: ./node_modules/ioredis/built/connectors/StandaloneConnector.js Module not found: Can't resolve 'net' Import trace: ioredis <- rateLimiter.ts <- apiKeys.ts <- @/lib/localDb <- CoolingConnectionsPanel.tsx (a "use client" component) The client panel imported `formatResetCountdown` from `@/lib/localDb` — the server-side DB re-export barrel — which transitively pulls better-sqlite3/ioredis (node:net) into the browser bundle. That violates the CLAUDE.md rule 'never barrel-import from localDb'. `formatResetCountdown` is a pure date-formatting function, so move its implementation to the client-safe `@/shared/utils/formatting` (alongside formatTime/formatDuration) and re-export it from db/providers/rateLimit.ts for the existing server callers + barrel. The panel now imports it directly from the shared util — no server code in the client bundle. Tests (Rule #18): - tests/unit/format-reset-countdown.test.ts (node:test, blocking test:unit) — pure-function coverage: null/past/invalid, s, m+s, h+m, ISO string. - tests/unit/ui/CoolingConnectionsPanel.test.tsx mock updated to the new module.
This commit is contained in:
committed by
GitHub
parent
415d159c80
commit
5ca28f2ff7
@@ -1,95 +1,94 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CoolingConnectionsPanel — Dashboard readout of connections currently in a
|
||||
* persisted 429 cooldown. Sourced from `useProviderConnections().connections`
|
||||
* filtered on `rateLimitedUntil`. Live human-readable countdown via the
|
||||
* existing `formatResetCountdown` helper re-exported by `@/lib/localDb`.
|
||||
*
|
||||
* Why this exists: Fix A (per-account 429 cascade not persisting) writes the
|
||||
* cooldown to `provider_connections.rate_limited_until` so the cascade
|
||||
* survives the request boundary and process restart. Without a visible
|
||||
* indicator the user has no way to see "OmniRoute learned that this key is
|
||||
* exhausted — and for how long". This panel makes the lesson visible.
|
||||
*
|
||||
* Acceptance criteria (Issue #1, fix scope D):
|
||||
* 1. Filters `connections` to those with a future `rateLimitedUntil`.
|
||||
* 2. Shows connection name + reset countdown.
|
||||
* 3. Re-evaluates every second so countdowns tick down.
|
||||
* 4. Renders nothing when no connection is cooling.
|
||||
* 5. Uses the same connection-shape type as ConnectionRow so the data flow
|
||||
* stays consistent with the rest of the dashboard.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { formatResetCountdown } from "@/lib/localDb";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CoolingConnectionsPanel — Dashboard readout of connections currently in a
|
||||
* persisted 429 cooldown. Sourced from `useProviderConnections().connections`
|
||||
* filtered on `rateLimitedUntil`. Live human-readable countdown via the
|
||||
* client-safe `formatResetCountdown` helper in `@/shared/utils/formatting`.
|
||||
*
|
||||
* Why this exists: Fix A (per-account 429 cascade not persisting) writes the
|
||||
* cooldown to `provider_connections.rate_limited_until` so the cascade
|
||||
* survives the request boundary and process restart. Without a visible
|
||||
* indicator the user has no way to see "OmniRoute learned that this key is
|
||||
* exhausted — and for how long". This panel makes the lesson visible.
|
||||
*
|
||||
* Acceptance criteria (Issue #1, fix scope D):
|
||||
* 1. Filters `connections` to those with a future `rateLimitedUntil`.
|
||||
* 2. Shows connection name + reset countdown.
|
||||
* 3. Re-evaluates every second so countdowns tick down.
|
||||
* 4. Renders nothing when no connection is cooling.
|
||||
* 5. Uses the same connection-shape type as ConnectionRow so the data flow
|
||||
* stays consistent with the rest of the dashboard.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { formatResetCountdown } from "@/shared/utils/formatting";
|
||||
import type { ConnectionRowConnection } from "./ConnectionRow";
|
||||
|
||||
export interface CoolingConnectionsPanelProps {
|
||||
readonly connections: readonly ConnectionRowConnection[];
|
||||
}
|
||||
|
||||
function isCoolingNow(connection: ConnectionRowConnection, now: number): boolean {
|
||||
if (!connection.rateLimitedUntil) return false;
|
||||
const until = new Date(connection.rateLimitedUntil).getTime();
|
||||
return Number.isFinite(until) && until > now;
|
||||
}
|
||||
|
||||
export default function CoolingConnectionsPanel(
|
||||
props: CoolingConnectionsPanelProps,
|
||||
) {
|
||||
|
||||
function isCoolingNow(connection: ConnectionRowConnection, now: number): boolean {
|
||||
if (!connection.rateLimitedUntil) return false;
|
||||
const until = new Date(connection.rateLimitedUntil).getTime();
|
||||
return Number.isFinite(until) && until > now;
|
||||
}
|
||||
|
||||
export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelProps) {
|
||||
const { connections } = props;
|
||||
// Tick once per second so the human-readable countdown updates.
|
||||
const [now, setNow] = useState<number>(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const cooling = connections.filter((c) => isCoolingNow(c, now));
|
||||
if (cooling.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-testid="cooling-connections-panel"
|
||||
className="mb-4 border-amber-500/40 bg-amber-500/5 p-4"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-2 w-2 animate-pulse rounded-full bg-amber-500"
|
||||
/>
|
||||
<h3 className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||
Currently cooling ({cooling.length})
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
These connections returned a 429 (rate-limit) on their last request.
|
||||
OmniRoute will skip them until the timer expires — no manual disable
|
||||
required.
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{cooling.map((c) => {
|
||||
const until = c.rateLimitedUntil!;
|
||||
const label =
|
||||
c.displayName || c.name || c.email || (c.id ? `connection ${c.id.slice(0, 8)}` : "connection");
|
||||
return (
|
||||
<li
|
||||
key={c.id ?? label}
|
||||
className="flex items-center justify-between rounded border border-amber-500/30 bg-background/40 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-medium">{label}</span>
|
||||
<span
|
||||
className="font-mono text-xs text-amber-700 dark:text-amber-300"
|
||||
data-testid="cooling-countdown"
|
||||
>
|
||||
{formatResetCountdown(until)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
// Tick once per second so the human-readable countdown updates.
|
||||
const [now, setNow] = useState<number>(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const cooling = connections.filter((c) => isCoolingNow(c, now));
|
||||
if (cooling.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="cooling-connections-panel"
|
||||
className="mb-4 rounded-card border border-amber-500/40 bg-amber-500/5 p-4 shadow-sm"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-2 w-2 animate-pulse rounded-full bg-amber-500"
|
||||
/>
|
||||
<h3 className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||
Currently cooling ({cooling.length})
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
These connections returned a 429 (rate-limit) on their last request. OmniRoute will skip
|
||||
them until the timer expires — no manual disable required.
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{cooling.map((c) => {
|
||||
const until = c.rateLimitedUntil!;
|
||||
const label =
|
||||
c.displayName ||
|
||||
c.name ||
|
||||
c.email ||
|
||||
(c.id ? `connection ${c.id.slice(0, 8)}` : "connection");
|
||||
return (
|
||||
<li
|
||||
key={c.id ?? label}
|
||||
className="flex items-center justify-between rounded border border-amber-500/30 bg-background/40 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-medium">{label}</span>
|
||||
<span
|
||||
className="font-mono text-xs text-amber-700 dark:text-amber-300"
|
||||
data-testid="cooling-countdown"
|
||||
>
|
||||
{formatResetCountdown(until)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -190,21 +190,9 @@ export function clearStaleCrashCooldowns(): { cleared: number } {
|
||||
return { cleared: toReset.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* T13: Format a reset countdown as a human-readable string: "2h 35m" or "4m 30s".
|
||||
* Returns null if resetAt is in the past or not set.
|
||||
*/
|
||||
export function formatResetCountdown(resetAt: string | number | null | undefined): string | null {
|
||||
if (!resetAt) return null;
|
||||
const resetTime = typeof resetAt === "number" ? resetAt : new Date(resetAt).getTime();
|
||||
if (isNaN(resetTime)) return null;
|
||||
const diffMs = resetTime - Date.now();
|
||||
if (diffMs <= 0) return null;
|
||||
const totalSeconds = Math.floor(diffMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
// T13: Format a reset countdown as a human-readable string ("2h 35m" / "4m 30s").
|
||||
// The implementation lives in the client-safe formatting utils so client
|
||||
// components (e.g. CoolingConnectionsPanel) can import it without pulling this
|
||||
// server-only DB module (better-sqlite3/ioredis) into the browser bundle.
|
||||
// Re-exported here for existing server-side callers and the db/providers barrel.
|
||||
export { formatResetCountdown } from "@/shared/utils/formatting";
|
||||
|
||||
@@ -157,3 +157,27 @@ export function truncateUrl(url: string | null | undefined, max = 50) {
|
||||
export function safePercentage(value: unknown): number | undefined {
|
||||
return typeof value === "number" && isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a reset countdown as a human-readable string: "2h 35m" or "4m 30s".
|
||||
* Returns null if resetAt is in the past or not set.
|
||||
*
|
||||
* Lives here (client-safe utils) — not in db/providers/rateLimit — so client
|
||||
* components can render a cooldown countdown without dragging the server-only
|
||||
* DB barrel (better-sqlite3/ioredis → node:net) into the browser bundle.
|
||||
* `rateLimit.ts` re-exports this for its server callers.
|
||||
*/
|
||||
export function formatResetCountdown(resetAt: string | number | null | undefined): string | null {
|
||||
if (!resetAt) return null;
|
||||
const resetTime = typeof resetAt === "number" ? resetAt : new Date(resetAt).getTime();
|
||||
if (isNaN(resetTime)) return null;
|
||||
const diffMs = resetTime - Date.now();
|
||||
if (diffMs <= 0) return null;
|
||||
const totalSeconds = Math.floor(diffMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
38
tests/unit/format-reset-countdown.test.ts
Normal file
38
tests/unit/format-reset-countdown.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { formatResetCountdown } from "@/shared/utils/formatting";
|
||||
|
||||
// Guards both the pure formatting behavior and the client-safe home of this
|
||||
// helper: it MUST live in @/shared/utils/formatting (not db/providers/rateLimit)
|
||||
// so client components can import it without pulling the server-only DB barrel
|
||||
// (better-sqlite3/ioredis → node:net) into the browser bundle. See PR #6155.
|
||||
|
||||
test("returns null for missing / past / invalid reset times", () => {
|
||||
assert.equal(formatResetCountdown(null), null);
|
||||
assert.equal(formatResetCountdown(undefined), null);
|
||||
assert.equal(formatResetCountdown(0), null);
|
||||
assert.equal(formatResetCountdown("not-a-date"), null);
|
||||
assert.equal(formatResetCountdown(Date.now() - 60_000), null);
|
||||
});
|
||||
|
||||
test("formats seconds-only remaining", () => {
|
||||
const out = formatResetCountdown(Date.now() + 30_000);
|
||||
assert.match(out ?? "", /^\d+s$/);
|
||||
});
|
||||
|
||||
test("formats minutes + seconds", () => {
|
||||
const out = formatResetCountdown(Date.now() + 5 * 60_000 + 30_000);
|
||||
assert.match(out ?? "", /^\d+m \d+s$/);
|
||||
});
|
||||
|
||||
test("formats hours + minutes", () => {
|
||||
const out = formatResetCountdown(Date.now() + 2 * 3_600_000 + 35 * 60_000);
|
||||
assert.match(out ?? "", /^\d+h \d+m$/);
|
||||
assert.ok((out ?? "").startsWith("2h"));
|
||||
});
|
||||
|
||||
test("accepts an ISO string as well as an epoch number", () => {
|
||||
const iso = new Date(Date.now() + 90_000).toISOString();
|
||||
assert.match(formatResetCountdown(iso) ?? "", /^(1m \d+s|\d+s)$/);
|
||||
});
|
||||
84
tests/unit/ui/CoolingConnectionsPanel.test.tsx
Normal file
84
tests/unit/ui/CoolingConnectionsPanel.test.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Regression guard for the base-red introduced by #6061: CoolingConnectionsPanel
|
||||
// imported `Card` from a non-existent `@/components/ui/card`, which passed the
|
||||
// PR→release fast-gates (they don't run `next build`) but broke `next build`
|
||||
// with `Module not found: Can't resolve '@/components/ui/card'`. Importing the
|
||||
// component here fails at module-load if that broken import ever comes back,
|
||||
// so this test fails-without-the-fix.
|
||||
|
||||
// `formatResetCountdown` lives in the client-safe `@/shared/utils/formatting`
|
||||
// module (imported directly by the panel — never via the server-only localDb
|
||||
// barrel, which would drag ioredis/node:net into the browser bundle). Stub it so
|
||||
// the countdown text is deterministic.
|
||||
vi.mock("@/shared/utils/formatting", () => ({
|
||||
formatResetCountdown: (v: string | number | null | undefined) => (v == null ? null : "in 5m"),
|
||||
}));
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
const PANEL_PATH = "@/app/(dashboard)/dashboard/providers/[id]/components/CoolingConnectionsPanel";
|
||||
|
||||
describe("CoolingConnectionsPanel", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("module loads and exports a default component (guards the import path)", async () => {
|
||||
const mod = await import(PANEL_PATH);
|
||||
expect(typeof mod.default).toBe("function");
|
||||
});
|
||||
|
||||
it("renders the panel with a countdown for a cooling connection", async () => {
|
||||
const { default: CoolingConnectionsPanel } = await import(PANEL_PATH);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
const future = new Date(Date.now() + 5 * 60_000).toISOString();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(CoolingConnectionsPanel, {
|
||||
connections: [{ id: "conn-abc12345", displayName: "My Key", rateLimitedUntil: future }],
|
||||
})
|
||||
);
|
||||
});
|
||||
const panel = container.querySelector("[data-testid='cooling-connections-panel']");
|
||||
expect(panel).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='cooling-countdown']")?.textContent).toContain(
|
||||
"in 5m"
|
||||
);
|
||||
expect(panel?.textContent).toContain("My Key");
|
||||
});
|
||||
|
||||
it("renders nothing when no connection is cooling", async () => {
|
||||
const { default: CoolingConnectionsPanel } = await import(PANEL_PATH);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(CoolingConnectionsPanel, {
|
||||
connections: [{ id: "conn-old", displayName: "Expired", rateLimitedUntil: past }],
|
||||
})
|
||||
);
|
||||
});
|
||||
expect(container.querySelector("[data-testid='cooling-connections-panel']")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user