From 5ca28f2ff7afb36431452e7a4aedf17a7a57d356 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 4 Jul 2026 06:35:07 -0300 Subject: [PATCH] fix(dashboard): resolve broken Card import breaking next build (base-red from #6061) (#6155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 here was only a styled container, so replace it with a
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. --- .../components/CoolingConnectionsPanel.tsx | 175 +++++++++--------- src/lib/db/providers/rateLimit.ts | 24 +-- src/shared/utils/formatting.ts | 24 +++ tests/unit/format-reset-countdown.test.ts | 38 ++++ .../unit/ui/CoolingConnectionsPanel.test.tsx | 84 +++++++++ 5 files changed, 239 insertions(+), 106 deletions(-) create mode 100644 tests/unit/format-reset-countdown.test.ts create mode 100644 tests/unit/ui/CoolingConnectionsPanel.test.tsx diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CoolingConnectionsPanel.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CoolingConnectionsPanel.tsx index 88fc250deb..63df0643f3 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/CoolingConnectionsPanel.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CoolingConnectionsPanel.tsx @@ -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(() => 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 ( - -
- -

- Currently cooling ({cooling.length}) -

-
-

- These connections returned a 429 (rate-limit) on their last request. - OmniRoute will skip them until the timer expires — no manual disable - required. -

-
    - {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 ( -
  • - {label} - - {formatResetCountdown(until)} - -
  • - ); - })} -
-
- ); + // Tick once per second so the human-readable countdown updates. + const [now, setNow] = useState(() => 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 ( +
+
+ +

+ Currently cooling ({cooling.length}) +

+
+

+ These connections returned a 429 (rate-limit) on their last request. OmniRoute will skip + them until the timer expires — no manual disable required. +

+
    + {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 ( +
  • + {label} + + {formatResetCountdown(until)} + +
  • + ); + })} +
+
+ ); } diff --git a/src/lib/db/providers/rateLimit.ts b/src/lib/db/providers/rateLimit.ts index 92b0d07a29..e9447152d3 100644 --- a/src/lib/db/providers/rateLimit.ts +++ b/src/lib/db/providers/rateLimit.ts @@ -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"; diff --git a/src/shared/utils/formatting.ts b/src/shared/utils/formatting.ts index e6c7fa07c9..37957d4833 100644 --- a/src/shared/utils/formatting.ts +++ b/src/shared/utils/formatting.ts @@ -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`; +} diff --git a/tests/unit/format-reset-countdown.test.ts b/tests/unit/format-reset-countdown.test.ts new file mode 100644 index 0000000000..07265fba66 --- /dev/null +++ b/tests/unit/format-reset-countdown.test.ts @@ -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)$/); +}); diff --git a/tests/unit/ui/CoolingConnectionsPanel.test.tsx b/tests/unit/ui/CoolingConnectionsPanel.test.tsx new file mode 100644 index 0000000000..869b5b30ec --- /dev/null +++ b/tests/unit/ui/CoolingConnectionsPanel.test.tsx @@ -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(); + }); +});