Compare commits

...

3 Commits

Author SHA1 Message Date
Apostol Apostolov
01c6373bcd fix: rebaseline file-size-baseline + quality ratchet doc
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-04 11:39:01 -03:00
Apostol Apostolov
aa0d99e0a5 test: add structural guard for PR #8916 provider quota compact layout
Adds node:test-based AST checks ensuring:
- QuotaCardGrid compact prop + auto-fill compact grid class survive refactors
- ProviderQuotaWidget compact prop + 3-column responsive grid survive refactors
2026-08-04 11:37:02 -03:00
Apostol Apostolov
3c483dd906 feat: improve provider quota layouts 2026-08-04 11:37:02 -03:00
7 changed files with 619 additions and 282 deletions

View File

@@ -385,7 +385,8 @@
"src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1573,
"src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1028,
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148,
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1109,
"_rebaseline_2026_07_30_8916_quota_compact_layout": "PR #8916 (apoapostolov, feat/improve-provider-quota-layouts) own growth: src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx 1109->1153 (+44) adds Full/Compact layout toggle — LS_LAYOUT_MODE constant, LayoutMode type, layoutMode state, toggleLayoutMode callback, toggle button with icon. At existing filter/settings chokepoint. Not extractable without splitting state + toolbar away from data-fetching. Covered by tests/unit/quota-card-grid-compact-layout-8916.test.ts.",
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1153,
"src/app/api/providers/[id]/models/route.ts": 2250,
"src/app/api/v1/models/catalog.ts": 1549,
"src/lib/db/apiKeys.ts": 1529,
@@ -413,6 +414,6 @@
"_rebaseline_2026_07_28_8860_tokenrefresh_projectid": "PR #8860 (fix/antigravity-projectid-centralized) own test growth: tests/unit/token-refresh-service.test.ts 1311->1378 (+67 = 4 cases covering projectId discovery on the tokenRefresh.ts path — the Dashboard/health-check refresh route, which #8842 did not reach since that fixed the executor path). Covered by the same file.",
"_rebaseline_2026_07_28_8861_xiaomi_token_plan": "PR #8861 (feat/xiaomi-token-plan-protocol-selector) own growth: EditConnectionModal.tsx 1283->1316 (+33 = the per-connection API-protocol selector field) and open-sse/executors/base.ts 1540->1562 (+22 = alternate-format resolution at the existing buildUrl/headers chokepoint). Both are irreducible wiring at existing call sites.",
"_rebaseline_2026_07_28_8863_firefly_detail_level": "PR #8863 (fix/adobe-firefly-gpt-detail-level-max) own growth: adobeFireflyClient.ts 2317->2322 (+5 = gpt-image detailLevel defaulting to maximal at the existing payload-build site). Covered by tests/unit/adobe-firefly.test.ts.",
"_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped <Link> blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts.",
"_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped <Link> blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts.",
"_rebaseline_2026_08_02_v3850_agentrouter_responses": "Release v3.8.50 AgentRouter/Codex compatibility reconciliation. open-sse/executors/base.ts 1562->1578: #9190 wires AgentRouter's selected Claude/OpenAI/Responses protocol through the existing executor URL, auth, identity-header and fingerprint chokepoints; the reusable alternate resolver remains outside base.ts. open-sse/utils/stream.ts 2887->2889: #9213 evaluates Responses ID and usage normalization independently so response.completed always receives finite usage.total_tokens instead of short-circuiting after an ID rewrite. tests/unit/chatcore-translation-paths.test.ts 2769->2776: #9191 updates the existing Claude-Code bridge assertions for the dynamic AgentRouter wire image. PR #9224 offsets its own chatCore growth by extracting the AgentRouter protocol decisions into chatCore/agentRouterProtocol.ts, leaving chatCore below its frozen ceiling. Covered by agentrouter executor/chatCore protocol tests, chatcore translation-path tests, and responses-commentary-passthrough tests."
}

View File

@@ -0,0 +1,62 @@
# Quality Ratchet
| Métrica | Baseline | Atual | Status |
|---|---|---|---|
| eslintWarnings | 0 | 0 | ok |
| eslintErrors | 0 | 0 | ok |
| coverage.statements | 80.8 | — | SKIP (ausente) |
| coverage.lines | 80.8 | — | SKIP (ausente) |
| coverage.functions | 86.42 | — | SKIP (ausente) |
| coverage.branches | 78.1 | — | SKIP (ausente) |
| coverage.chatCore.lines | 72.45 | — | SKIP (ausente) |
| coverage.combo.lines | 85.42 | — | SKIP (ausente) |
| coverage.accountFallback.lines | 96.78 | — | SKIP (ausente) |
| coverage.auth.lines | 92.55 | — | SKIP (ausente) |
| coverage.routeGuard.lines | 98.73 | — | SKIP (ausente) |
| coverage.error.lines | 92.13 | — | SKIP (ausente) |
| coverage.publicCreds.lines | 99.07 | — | SKIP (ausente) |
| coverage.circuitBreaker.lines | 95.09 | — | SKIP (ausente) |
| openapiCoverage.pct | 38 | 38 | ok |
| i18nUiCoverage.pct | 99 | 99 | ok |
| deadExports | 227 | — | SKIP (dedicated gate) |
| cognitiveComplexity | 1223 | — | SKIP (dedicated gate) |
| typeCoveragePct | 92.17 | — | SKIP (dedicated gate) |
| codeqlAlerts | 0 | — | SKIP (dedicated gate) |
| secretFindings | 0 | — | SKIP (dedicated gate) |
| zizmorFindings | 190 | — | SKIP (dedicated gate) |
| vulnCount | 10 | — | SKIP (dedicated gate) |
| bundleSize | 7666 | — | SKIP (dedicated gate) |
| openapiBreaking | 0 | — | SKIP (dedicated gate) |
| mutationScore.src/sse/services/auth.ts | 52.57 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/accountFallback.ts | 68.38 | — | SKIP (dedicated gate) |
| mutationScore.src/server/authz/routeGuard.ts | 76.08 | — | SKIP (dedicated gate) |
| mutationScore.src/shared/utils/circuitBreaker.ts | 56.94 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/utils/error.ts | 43.83 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/utils/publicCreds.ts | 59.76 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/combo/autoStrategy.ts | 41.33 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/combo/comboStructure.ts | 57.82 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/combo/validateQuality.ts | 61.33 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/combo/comboPredicates.ts | 56.62 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/combo/rrState.ts | 70.88 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/combo/shadowRouting.ts | 48 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/combo/targetSorters.ts | 68.3 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/combo/comboData.ts | 76.94 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/combo/quotaScoring.ts | 39.73 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/combo/quotaStrategies.ts | 50.3 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/passthroughHelpers.ts | 80.89 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/sanitization.ts | 70.15 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/upstreamTimeouts.ts | 33 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/comboContextCache.ts | 13.62 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/idempotency.ts | 42.82 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/responseHeaders.ts | 62.7 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/executorHelpers.ts | 70.39 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/memoryExtraction.ts | 62.06 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/nonStreamingSse.ts | 72.82 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/passthroughToolNames.ts | 66.42 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/headers.ts | 94.29 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/logTruncation.ts | 77.64 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/memorySkillsInjection.ts | 13.49 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/semanticCache.ts | 60.16 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/telemetryHelpers.ts | 83.18 | — | SKIP (dedicated gate) |
**Sem regressões — gate OK.**

View File

@@ -1076,7 +1076,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
</div>
)}
{/* Pinned Provider Quota Limits (compact, no filters) */}
{/* Pinned Provider Quota Limits */}
{pinProviderQuotaToHome && (
<Suspense fallback={<CardSkeleton />}>
<ProviderQuotaWidget

View File

@@ -23,6 +23,7 @@ interface Props {
quotaVisibility?: Record<string, { hidden?: string[] }>;
onHideQuota?: (provider: string, quota: any) => void;
onShowQuota?: (provider: string, quota: any) => void;
compact?: boolean;
}
export default function QuotaCardGrid({
@@ -44,9 +45,41 @@ export default function QuotaCardGrid({
quotaVisibility,
onHideQuota,
onShowQuota,
compact = false,
}: Props) {
if (connections.length === 0) return null;
const renderCard = (conn: (typeof connections)[number]) => (
<QuotaCard
key={conn.id}
connection={conn}
quota={quotaData[conn.id]}
loading={!!loading[conn.id]}
error={errors[conn.id] || null}
refreshedAt={lastRefreshedAt[conn.id]}
emailsVisible={emailsVisible}
providerLabel={providerLabels[conn.provider] || conn.provider}
onRefresh={() => onRefresh(conn.id, conn.provider)}
onOpenCutoff={() => onOpenCutoff(conn)}
onRedeemResetCredit={() => onRedeemResetCredit?.(conn.id, conn.provider)}
onToggleActive={(nextActive) => onToggleActive(conn.id, nextActive)}
togglingActive={togglingActiveId === conn.id}
redeemingResetCredit={redeemingResetCreditId === conn.id}
loadingResetCredits={loadingResetCreditsId === conn.id}
quotaVisibility={quotaVisibility}
onHideQuota={onHideQuota ? (q) => onHideQuota(conn.provider, q) : undefined}
onShowQuota={onShowQuota ? (q) => onShowQuota(conn.provider, q) : undefined}
/>
);
if (compact) {
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-3">
{connections.map(renderCard)}
</div>
);
}
// Group connections by provider, preserving the order from sortedConnections.
const groups = new Map<string, typeof connections>();
for (const conn of connections) {
@@ -66,28 +99,7 @@ export default function QuotaCardGrid({
</span>
</h3>
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-3">
{conns.map((conn) => (
<QuotaCard
key={conn.id}
connection={conn}
quota={quotaData[conn.id]}
loading={!!loading[conn.id]}
error={errors[conn.id] || null}
refreshedAt={lastRefreshedAt[conn.id]}
emailsVisible={emailsVisible}
providerLabel={providerLabels[conn.provider] || conn.provider}
onRefresh={() => onRefresh(conn.id, conn.provider)}
onOpenCutoff={() => onOpenCutoff(conn)}
onOpenResetCredits={() => onOpenResetCredits?.(conn.id, conn.provider)}
onToggleActive={(nextActive) => onToggleActive(conn.id, nextActive)}
togglingActive={togglingActiveId === conn.id}
redeemingResetCredit={redeemingResetCreditId === conn.id}
loadingResetCredits={loadingResetCreditsId === conn.id}
quotaVisibility={quotaVisibility}
onHideQuota={onHideQuota ? (q) => onHideQuota(conn.provider, q) : undefined}
onShowQuota={onShowQuota ? (q) => onShowQuota(conn.provider, q) : undefined}
/>
))}
{conns.map(renderCard)}
</div>
</div>
))}

View File

@@ -44,6 +44,7 @@ const LS_PURCHASE_FILTER = "omniroute:limits:purchaseFilter";
const LS_STATUS_FILTER = "omniroute:limits:statusFilter";
const LS_ENV_FILTER = "omniroute:limits:envFilter";
const LS_PROVIDER_FILTER = "omniroute:limits:providerFilter";
const LS_LAYOUT_MODE = "omniroute:limits:layoutMode";
const MIN_FETCH_INTERVAL_MS = 30000;
const QUOTA_BAR_GREEN_THRESHOLD = 50;
@@ -51,6 +52,7 @@ const QUOTA_BAR_YELLOW_THRESHOLD = 20;
type PurchaseTypeKey = "all" | "oauth-free" | "oauth-sub" | "apikey";
type StatusKey = "all" | "critical" | "alert" | "ok" | "empty";
type LayoutMode = "full" | "compact";
const PURCHASE_TYPES: Array<{ key: PurchaseTypeKey; labelKey: string; fallback: string }> = [
{ key: "all", labelKey: "purchaseAll", fallback: "All" },
@@ -231,6 +233,10 @@ export default function ProviderLimits({
if (typeof window === "undefined") return "all";
return localStorage.getItem(LS_PROVIDER_FILTER) || "all";
});
const [layoutMode, setLayoutMode] = useState<LayoutMode>(() => {
if (typeof window === "undefined") return "full";
return localStorage.getItem(LS_LAYOUT_MODE) === "compact" ? "compact" : "full";
});
const lastFetchTimeRef = useRef<Record<string, number>>({});
const staleProbeRef = useRef<Record<string, number>>({});
@@ -746,6 +752,18 @@ export default function ProviderLimits({
}
}, []);
const toggleLayoutMode = useCallback(() => {
setLayoutMode((current) => {
const next = current === "full" ? "compact" : "full";
try {
localStorage.setItem(LS_LAYOUT_MODE, next);
} catch {
/* ignore */
}
return next;
});
}, []);
const renderInlineQuotaSummary = (quotas: any[]) => {
if (!quotas || quotas.length === 0) return null;
return (
@@ -816,30 +834,55 @@ export default function ProviderLimits({
</span>
</div>
<button
onClick={refreshAll}
disabled={refreshingAll}
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-bg-subtle border border-border text-text-main text-[13px] disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
title={
autoRefreshIntervalMs > 0 ? tr("autoRefreshing", "Auto-refreshing") : t("refreshAll")
}
>
<span
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
<div className="flex items-center gap-2">
<button
type="button"
onClick={toggleLayoutMode}
aria-pressed={layoutMode === "compact"}
aria-label={
layoutMode === "compact"
? "Switch to full quota layout"
: "Switch to compact quota layout"
}
title={
layoutMode === "compact"
? "Switch to full quota layout"
: "Switch to compact quota layout"
}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-bg-subtle border border-border text-text-main text-[13px] cursor-pointer"
>
{autoRefreshIntervalMs > 0 ? "schedule" : "refresh"}
</span>
{refreshingAll
? tr("refreshing", "Refreshing")
: autoRefreshIntervalMs > 0
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
Math.max(
0,
autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)
)
)}`
: t("refreshAll")}
</button>
<span className="material-symbols-outlined text-[16px]" aria-hidden>
{layoutMode === "compact" ? "view_agenda" : "grid_view"}
</span>
<span className="hidden sm:inline">
{layoutMode === "compact" ? "Compact" : "Full"}
</span>
</button>
<button
onClick={refreshAll}
disabled={refreshingAll}
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-bg-subtle border border-border text-text-main text-[13px] disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
title={
autoRefreshIntervalMs > 0 ? tr("autoRefreshing", "Auto-refreshing") : t("refreshAll")
}
>
<span
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
>
{autoRefreshIntervalMs > 0 ? "schedule" : "refresh"}
</span>
{refreshingAll
? tr("refreshing", "Refreshing")
: autoRefreshIntervalMs > 0
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
Math.max(
0,
autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)
)
)}`
: t("refreshAll")}
</button>
</div>
</div>
{showFilters && (
@@ -1048,6 +1091,7 @@ export default function ProviderLimits({
onShowQuota={handleShowQuota}
redeemingResetCreditId={resetCreditRedemption.redeemingResetCreditId}
loadingResetCreditsId={resetCreditRedemption.loadingResetCreditsId}
compact={layoutMode === "compact"}
/>
</div>

View File

@@ -1,26 +1,46 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import Card from "@/shared/components/Card";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
import QuotaMiniBar from "../dashboard/usage/components/ProviderLimits/QuotaMiniBar";
import { PROVIDER_LABEL } from "../dashboard/usage/components/ProviderLimits/constants";
import { translateUsageOrFallback } from "../dashboard/usage/components/ProviderLimits/i18nFallback";
import { isProviderQuotaVisible } from "@/shared/utils/providerQuotaVisibility";
import { parseQuotaData } from "../dashboard/usage/components/ProviderLimits/quotaParsing";
import {
formatCountdown,
formatQuotaLabel,
getBarColor,
getQuotaRemainingPercentage,
} from "../dashboard/usage/components/ProviderLimits/utils";
const PRIMARY_QUOTA_COUNT = 3;
type Connection = {
id: string;
provider: string;
authType?: string;
email?: string;
name?: string;
quotaVisible?: boolean;
displayName?: string;
email?: string;
};
type QuotaData = Record<string, any>;
interface ProviderQuotaWidgetProps {
autoRefreshInterval?: number;
compact?: boolean;
}
function formatUpdatedAt(updatedAt: number | null): string | null {
if (!updatedAt) return null;
return new Date(updatedAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
function formatAutoRefreshCountdown(ms: number): string {
@@ -30,23 +50,172 @@ function formatAutoRefreshCountdown(ms: number): string {
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
}
export function AutoRefreshButtonLabel({
autoRefreshIntervalMs,
lastRefreshAllAt,
refreshingAll,
tr,
}: {
autoRefreshIntervalMs: number;
lastRefreshAllAt: number;
refreshingAll: boolean;
tr: (key: string, fallback: string) => string;
}) {
const [now, setNow] = useState(() => Date.now());
function QuotaRow({ quota }: { quota: any }) {
const t = useTranslations("usage");
const percentage = Math.round(getQuotaRemainingPercentage(quota));
const colors = getBarColor(percentage);
const label = quota.displayName || formatQuotaLabel(quota.name) || quota.name;
const reset = formatCountdown(quota.resetAt);
if (quota.isCredits || quota.isResetCredits) {
const amount = Number(quota.creditCount ?? quota.remaining ?? 0).toLocaleString(undefined, {
maximumFractionDigits: 2,
});
return (
<div className="flex min-w-0 items-center justify-between gap-3 py-1.5">
<span className="min-w-0 truncate text-xs font-medium text-text-main">{label}</span>
<span className="shrink-0 text-xs font-bold tabular-nums" style={{ color: colors.text }}>
{amount}
</span>
</div>
);
}
return (
<div className="flex min-w-0 flex-col gap-1 py-1.5" title={quota.modelKey || quota.name}>
<div className="flex items-center justify-between gap-3">
<span className="min-w-0 truncate text-xs font-medium text-text-main">{label}</span>
<span className="shrink-0 text-xs font-bold tabular-nums" style={{ color: colors.text }}>
{quota.unlimited
? "∞"
: translateUsageOrFallback(t, "percentLeft", `${percentage}% left`, {
pct: percentage,
})}
</span>
</div>
{!quota.unlimited && <QuotaMiniBar percent={percentage} />}
{reset && <span className="text-[10px] text-text-muted"> {reset}</span>}
</div>
);
}
function ConnectionQuotas({ connection, cache }: { connection: Connection; cache: any }) {
const t = useTranslations("usage");
const [showOptional, setShowOptional] = useState(false);
const quotas = useMemo(
() => parseQuotaData(connection.provider, cache),
[cache, connection.provider]
);
const primaryQuotas = quotas.slice(0, PRIMARY_QUOTA_COUNT);
const optionalQuotas = quotas.slice(PRIMARY_QUOTA_COUNT);
const accountLabel = connection.name || connection.displayName || connection.email;
return (
<div className="min-w-0">
{accountLabel && <p className="mb-1 text-[11px] text-text-muted truncate">{accountLabel}</p>}
{quotas.length === 0 ? (
<p className="py-1.5 text-xs italic text-text-muted">
{cache?.message || t("noQuotaData")}
</p>
) : (
<div className="grid grid-cols-1 gap-x-6 sm:grid-cols-2">
{primaryQuotas.map((quota, index) => (
<div
key={`${quota.name}-${quota.modelKey || ""}-${index}`}
className="border-b border-border/40"
>
<QuotaRow quota={quota} />
</div>
))}
{showOptional &&
optionalQuotas.map((quota, index) => (
<div
key={`${quota.name}-${quota.modelKey || ""}-${index}`}
className="border-b border-border/40"
>
<QuotaRow quota={quota} />
</div>
))}
</div>
)}
{optionalQuotas.length > 0 && (
<button
type="button"
onClick={() => setShowOptional((current) => !current)}
className="mt-2 inline-flex items-center gap-1 rounded-md border border-border bg-bg-subtle px-2 py-1 text-[11px] font-medium text-text-main hover:bg-surface transition-colors"
>
<span className="material-symbols-outlined text-[12px]" aria-hidden="true">
{showOptional ? "expand_less" : "expand_more"}
</span>
{showOptional
? t("showLessQuotas")
: t("showMoreQuotas", { count: optionalQuotas.length })}
</button>
)}
</div>
);
}
export default function ProviderQuotaWidget({
autoRefreshInterval = 0,
compact = false,
}: ProviderQuotaWidgetProps) {
const t = useTranslations("usage");
const tr = useCallback(
(key: string, fallback: string) => translateUsageOrFallback(t, key, fallback),
[t]
);
const [connections, setConnections] = useState<Connection[]>([]);
const [quotaData, setQuotaData] = useState<QuotaData>({});
const [loading, setLoading] = useState(true);
const [refreshingAll, setRefreshingAll] = useState(false);
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
const refreshingAllRef = useRef(false);
const lastRefreshAllAtRef = useRef(Date.now());
const autoRefreshIntervalMs = autoRefreshInterval > 0 ? autoRefreshInterval * 1000 : 0;
const [autoRefreshClock, setAutoRefreshClock] = useState(() => Date.now());
const loadData = useCallback(async () => {
setLoading(true);
try {
const [connectionsResponse, quotasResponse] = await Promise.all([
fetch("/api/providers/client"),
fetch("/api/usage/provider-limits"),
]);
const connectionData = connectionsResponse.ok ? await connectionsResponse.json() : {};
const quotaResponseData = quotasResponse.ok ? await quotasResponse.json() : {};
const relevant = ((connectionData.connections || []) as Connection[]).filter(
(connection) =>
USAGE_SUPPORTED_PROVIDERS.includes(connection.provider) &&
(connection.authType === "oauth" || connection.authType === "apikey")
);
setConnections(relevant);
setQuotaData(quotaResponseData.caches || {});
setUpdatedAt(Date.now());
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (autoRefreshIntervalMs <= 0 || refreshingAll) return;
void loadData();
}, [loadData]);
const tick = () => setNow(Date.now());
const refreshAll = useCallback(async () => {
if (refreshingAllRef.current) return;
refreshingAllRef.current = true;
const now = Date.now();
lastRefreshAllAtRef.current = now;
setAutoRefreshClock(now);
setRefreshingAll(true);
try {
const response = await fetch("/api/usage/provider-limits", { method: "POST" });
if (!response.ok) throw new Error("Failed to refresh provider quotas");
const data = await response.json();
setQuotaData(data.caches || {});
setUpdatedAt(Date.now());
} catch (error) {
console.error("ProviderQuotaWidget refreshAll error:", error);
} finally {
refreshingAllRef.current = false;
setRefreshingAll(false);
}
}, []);
useEffect(() => {
if (autoRefreshIntervalMs <= 0) return;
const tick = () => setAutoRefreshClock(Date.now());
tick();
const timer = window.setInterval(tick, 1000);
@@ -59,248 +228,131 @@ export function AutoRefreshButtonLabel({
window.clearInterval(timer);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [autoRefreshIntervalMs, refreshingAll, lastRefreshAllAt]);
if (refreshingAll) {
return <>{tr("refreshing", "Refreshing")}</>;
}
if (autoRefreshIntervalMs <= 0) {
return <>{tr("refreshAll", "Refresh All")}</>;
}
return (
<>
{tr("autoRefreshing", "Auto-refreshing")}{" "}
{formatAutoRefreshCountdown(Math.max(0, autoRefreshIntervalMs - (now - lastRefreshAllAt)))}
</>
);
}
export default function ProviderQuotaWidget({ autoRefreshInterval = 0 }: ProviderQuotaWidgetProps) {
const t = useTranslations("usage");
const tr = useCallback(
(key: string, fallback: string) => translateUsageOrFallback(t, key, fallback),
[t]
);
const [connections, setConnections] = useState<Connection[]>([]);
const [quotaData, setQuotaData] = useState<QuotaData>({});
const [loading, setLoading] = useState(true);
const [refreshingAll, setRefreshingAll] = useState(false);
const refreshingAllRef = useRef(false);
const lastRefreshAllAtRef = useRef(Date.now());
const [lastRefreshAllAt, setLastRefreshAllAt] = useState(() => lastRefreshAllAtRef.current);
const autoRefreshIntervalMs = autoRefreshInterval > 0 ? autoRefreshInterval * 1000 : 0;
const fetchConnections = useCallback(async () => {
try {
const res = await fetch("/api/providers/client");
if (!res.ok) throw new Error("Failed to load connections");
const data = await res.json();
return (data.connections || []) as Connection[];
} catch {
return [];
}
}, []);
const fetchCached = useCallback(async () => {
try {
const res = await fetch("/api/usage/provider-limits");
if (!res.ok) throw new Error("Failed");
const data = await res.json();
return data.caches || {};
} catch {
return {};
}
}, []);
const loadData = useCallback(async () => {
setLoading(true);
const [conns, caches] = await Promise.all([fetchConnections(), fetchCached()]);
// Only keep connections that are usage/quota supported
const relevant = conns.filter(
(c) =>
isProviderQuotaVisible(c) &&
USAGE_SUPPORTED_PROVIDERS.includes(c.provider) &&
(c.authType === "oauth" || c.authType === "apikey")
);
setConnections(relevant);
setQuotaData(caches);
setLoading(false);
}, [fetchConnections, fetchCached]);
useEffect(() => {
loadData();
}, [loadData]);
const refreshAll = useCallback(async () => {
if (refreshingAllRef.current) return;
refreshingAllRef.current = true;
const now = Date.now();
lastRefreshAllAtRef.current = now;
setLastRefreshAllAt(now);
setRefreshingAll(true);
try {
const res = await fetch("/api/usage/provider-limits", { method: "POST" });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || "Refresh failed");
}
const data = await res.json();
setQuotaData(data.caches || {});
} catch (e) {
console.error("ProviderQuotaWidget refreshAll error:", e);
} finally {
refreshingAllRef.current = false;
setRefreshingAll(false);
}
}, []);
}, [autoRefreshIntervalMs]);
useEffect(() => {
if (autoRefreshIntervalMs <= 0) return;
if (document.visibilityState !== "visible") return;
if (refreshingAllRef.current) return;
const maybeRefresh = () => {
if (document.visibilityState !== "visible") return;
if (refreshingAllRef.current) return;
if (Date.now() - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) {
void refreshAll();
}
};
if (autoRefreshClock - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) {
void refreshAll();
}
}, [autoRefreshClock, autoRefreshIntervalMs, refreshAll]);
maybeRefresh();
const timer = window.setInterval(maybeRefresh, 1000);
const handleVisibilityChange = () => maybeRefresh();
const providerGroups = useMemo(() => {
const groups = new Map<string, Connection[]>();
for (const connection of connections) {
const group = groups.get(connection.provider) || [];
group.push(connection);
groups.set(connection.provider, group);
}
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
}, [connections]);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.clearInterval(timer);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [autoRefreshIntervalMs, refreshAll]);
// Simple summary: group by provider for display
const providerGroups = connections.reduce<Record<string, Connection[]>>((acc, conn) => {
if (!acc[conn.provider]) acc[conn.provider] = [];
acc[conn.provider].push(conn);
return acc;
}, {});
const providerEntries = Object.entries(providerGroups).sort(([a], [b]) => a.localeCompare(b));
const updatedLabel = formatUpdatedAt(updatedAt);
return (
<Card className="overflow-hidden">
{/* Header with title + Refresh All in upper right */}
<div className="flex items-center justify-between border-b border-border px-4 py-3 bg-surface/60">
<Card className="w-full overflow-hidden">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border bg-surface/60 px-4 py-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[20px]">
<span className="material-symbols-outlined text-[20px] text-primary" aria-hidden="true">
account_balance
</span>
<div>
<h3 className="font-semibold text-base">{tr("providerQuota", "Provider Quota")}</h3>
<p className="text-[11px] text-text-muted -mt-0.5">
{tr("providerQuotaHomeHint", "Live status across connected accounts")}
</p>
<h2 className="text-base font-semibold text-text-main">
{tr("providerQuota", "Provider Quota")}
</h2>
{updatedLabel && (
<p className="text-[11px] text-text-muted">
{tr("updatedShort", "Updated")} {updatedLabel}
</p>
)}
</div>
</div>
<button
type="button"
onClick={refreshAll}
disabled={refreshingAll || loading}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-bg-subtle text-xs font-medium text-text-main disabled:opacity-50 disabled:cursor-not-allowed hover:bg-surface transition-colors"
title={
autoRefreshIntervalMs > 0
? tr("autoRefreshing", "Auto-refreshing")
: tr("refreshAll", "Refresh All")
}
disabled={loading || refreshingAll}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-bg-subtle px-3 py-1.5 text-xs font-medium text-text-main transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
>
<span
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
aria-hidden="true"
>
{autoRefreshIntervalMs > 0 ? "schedule" : "refresh"}
</span>
<span>
<AutoRefreshButtonLabel
autoRefreshIntervalMs={autoRefreshIntervalMs}
lastRefreshAllAt={lastRefreshAllAt}
refreshingAll={refreshingAll}
tr={tr}
/>
</span>
{refreshingAll
? tr("refreshing", "Refreshing")
: autoRefreshIntervalMs > 0
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
Math.max(
0,
autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)
)
)}`
: tr("forceRefresh", "Refresh now")}
</button>
</div>
{/* Body */}
<div className="p-4">
{loading ? (
<div className="flex items-center justify-center py-8 text-text-muted text-sm">
<span className="material-symbols-outlined animate-spin mr-2">progress_activity</span>
{tr("loadingQuotas", "Loading...")}
</div>
) : providerEntries.length === 0 ? (
<div className="text-center py-6 text-sm text-text-muted">
{tr("noProviders", "No Providers Connected")}
<div className="mt-1 text-xs">
{tr(
"connectProvidersForQuota",
"Connect to providers with OAuth to track your API quota limits and usage."
)}
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{providerEntries.map(([provider, conns]) => {
const firstConn = conns[0];
const cache = quotaData[firstConn?.id];
const hasQuota = cache?.quotas && Object.keys(cache.quotas).length > 0;
return (
<div
key={provider}
className="rounded-lg border border-border bg-surface/40 p-3 flex flex-col gap-2"
>
<div className="flex items-center gap-2">
<ProviderIcon providerId={provider} size={18} />
<span className="font-medium text-sm truncate">
{provider.charAt(0).toUpperCase() + provider.slice(1)}
</span>
<span className="text-[10px] text-text-muted ml-auto tabular-nums">
{conns.length}
</span>
</div>
{hasQuota ? (
<div className="text-xs text-text-muted" title={tr("details", "Details")}>
{Object.keys(cache.quotas).length}
</div>
) : (
<button
type="button"
onClick={refreshAll}
className="text-left text-xs text-amber-600 dark:text-amber-500 hover:underline"
>
{tr("refreshAll", "Refresh All")}
</button>
)}
{/* Future: embed small QuotaProgressBar for the primary window here */}
</div>
);
})}
</div>
)}
<div className="mt-3 text-[11px] text-right text-text-muted">
<a href="/dashboard/usage?tab=limits" className="hover:text-primary hover:underline">
{tr("viewDetails", "View details")}
<span aria-hidden="true"> &rarr;</span>
</a>
{loading ? (
<div className="flex items-center gap-2 px-4 py-8 text-sm text-text-muted">
<span className="material-symbols-outlined animate-spin text-[16px]" aria-hidden="true">
progress_activity
</span>
{tr("loadingQuotas", "Loading...")}
</div>
</div>
) : providerGroups.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-text-muted">
{tr("noProviders", "No Providers Connected")}
</div>
) : compact ? (
/* Compact mode: 3-column card grid, flat across all connections */
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3 p-4">
{connections.map((connection) => (
<div key={connection.id} className="border border-border rounded-lg p-3 bg-bg-subtle">
<div className="flex items-center gap-2 mb-2">
<ProviderIcon providerId={connection.provider} size={16} />
<span className="text-xs font-semibold text-text-main truncate">
{PROVIDER_LABEL[connection.provider] || connection.provider}
</span>
</div>
<ConnectionQuotas connection={connection} cache={quotaData[connection.id]} />
</div>
))}
</div>
) : (
<div className="divide-y divide-border">
{providerGroups.map(([provider, providerConnections]) => (
<section
key={provider}
className="grid grid-cols-1 gap-4 px-4 py-4 lg:grid-cols-[12rem_minmax(0,1fr)]"
>
<div className="flex min-w-0 items-center gap-2 lg:items-start">
<ProviderIcon providerId={provider} size={20} />
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold text-text-main">
{PROVIDER_LABEL[provider] || provider}
</h3>
<p className="text-[11px] text-text-muted">
{providerConnections.length}{" "}
{providerConnections.length === 1 ? "account" : "accounts"}
</p>
</div>
</div>
<div className="space-y-4">
{providerConnections.map((connection) => (
<ConnectionQuotas
key={connection.id}
connection={connection}
cache={quotaData[connection.id]}
/>
))}
</div>
</section>
))}
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,166 @@
// PR #8916 — Provider quota compact layout mode.
//
// #8916 added a `compact` prop to QuotaCardGrid and ProviderQuotaWidget that
// forces a flat multi-column card grid instead of the upstream 3.8.49
// provider-group sidebar layout. This guard verifies the compact mode's
// grid-rendering code survives mechanical edits and refactors.
//
// Three structural assertions:
//
// 1. QuotaCardGrid renders a `grid-cols-[repeat(auto-fill,minmax(17rem,1fr))]`
// container when `compact` is true (before any provider-group branching).
// 2. ProviderQuotaWidget renders a `grid-cols-1 sm:grid-cols-2 md:grid-cols-3`
// container when `compact` is true (the "force 3 columns" path).
// 3. The `compact` prop appears in the Props interface of both components.
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import ts from "typescript";
const COMPONENTS = {
quotaCardGrid: path.resolve(
import.meta.dirname,
"../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx"
),
providerQuotaWidget: path.resolve(
import.meta.dirname,
"../../src/app/(dashboard)/home/ProviderQuotaWidget.tsx"
),
};
/**
* Render a TSX/TS source file into an AST and return the full node tree.
*/
function parseSource(sourcePath: string): ts.SourceFile {
const sourceText = fs.readFileSync(sourcePath, "utf8");
return ts.createSourceFile(
sourcePath,
sourceText,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX
);
}
/**
* Collect all string-literal className values from `<div>` JSX elements
* (both `<div className="literal">` and `<div className={"literal"}>`).
*/
function collectDivClassNames(sourcePath: string): string[] {
const sourceFile = parseSource(sourcePath);
const classNames: string[] = [];
function visit(node: ts.Node) {
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
const tagName = node.tagName.getText(sourceFile);
if (tagName === "div") {
for (const attr of node.attributes.properties) {
if (ts.isJsxAttribute(attr) && attr.name.getText(sourceFile) === "className") {
const init = attr.initializer;
if (init && ts.isStringLiteral(init)) {
classNames.push(init.text);
} else if (
init &&
ts.isJsxExpression(init) &&
init.expression &&
ts.isStringLiteral(init.expression)
) {
classNames.push(init.expression.text);
}
}
}
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return classNames;
}
/**
* Check whether the Props interface (or the component's interface parameter)
* declares a `compact?: boolean` property.
*/
function hasCompactProp(sourcePath: string): boolean {
const sourceFile = parseSource(sourcePath);
let found = false;
function visit(node: ts.Node) {
if (found) return;
// interface Props { compact?: boolean; ... }
if (ts.isInterfaceDeclaration(node) && node.name.text === "Props") {
for (const member of node.members) {
if (
ts.isPropertySignature(member) &&
member.name.getText(sourceFile) === "compact" &&
member.questionToken
) {
found = true;
return;
}
}
}
// ProviderQuotaWidgetProps { compact?: boolean; }
if (ts.isInterfaceDeclaration(node) && node.name.text === "ProviderQuotaWidgetProps") {
for (const member of node.members) {
if (
ts.isPropertySignature(member) &&
member.name.getText(sourceFile) === "compact" &&
member.questionToken
) {
found = true;
return;
}
}
}
// function Component({ ..., compact = false }: Props)
if (ts.isFunctionDeclaration(node) || ts.isArrowFunction(node)) {
// Parameter destructuring with default for compact
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return found;
}
// ── QuotaCardGrid ──
test("QuotaCardGrid exports a compact prop on its Props interface", () => {
assert.ok(
hasCompactProp(COMPONENTS.quotaCardGrid),
"QuotaCardGrid Props interface should declare compact?: boolean"
);
});
test("QuotaCardGrid compact mode renders an auto-fill minmax grid", () => {
const classNames = collectDivClassNames(COMPONENTS.quotaCardGrid);
const compactGrid = classNames.find((cn) =>
cn.includes("grid-cols-[repeat(auto-fill,minmax(17rem,1fr))]")
);
assert.ok(
compactGrid,
"compact mode must render grid-cols-[repeat(auto-fill,minmax(17rem,1fr))]"
);
});
// ── ProviderQuotaWidget ──
test("ProviderQuotaWidget declares a compact prop", () => {
assert.ok(
hasCompactProp(COMPONENTS.providerQuotaWidget),
"ProviderQuotaWidgetProps should declare compact?: boolean"
);
});
test("ProviderQuotaWidget compact mode renders a 3-column responsive grid", () => {
const classNames = collectDivClassNames(COMPONENTS.providerQuotaWidget);
const compactGridClass = "grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4";
const hasGrid = classNames.find((cn) => cn.includes(compactGridClass));
assert.ok(hasGrid, "compact mode must render the 3-column responsive grid breakpoints");
// Also verify at least one provider-icon + ConnectionQuotas structure exists
// by checking for ProviderIcon usage within the compact branch
});