fix(dashboard): resolve broken Card import breaking next build (base-red from #6061) (#6155)

* 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:
Diego Rodrigues de Sa e Souza
2026-07-04 06:35:07 -03:00
committed by GitHub
parent 415d159c80
commit 5ca28f2ff7
5 changed files with 239 additions and 106 deletions

View File

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

View File

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

View File

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

View 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)$/);
});

View 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();
});
});