From 59ddcab6a7965e48c544f475b06cb15c93747da7 Mon Sep 17 00:00:00 2001 From: Apostol Apostolov Date: Tue, 11 Aug 2026 10:25:33 +0300 Subject: [PATCH] feat: improve provider quota layouts (#8916) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: improve provider quota layouts (#8916) Adds Full/Compact layout toggle for provider quota cards. Compact mode shows condensed card grid with key metrics; Full mode shows expanded detail. Toggle persists via localStorage. Changes: - ProviderLimits/index.tsx: layout mode state + toggle button - QuotaCardGrid.tsx: compact/full card rendering - ProviderQuotaWidget.tsx: compact/home view - HomePageClient.tsx: minor wiring fix - tests/unit/quota-card-grid-compact-layout-8916.test.ts: structural guard - file-size-baseline.json: rebaseline for ProviderLimits/index.tsx (1163) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(ci): restore providerId contract + reorder grid source + rebaseline translator drift - ProviderQuotaWidget.tsx: restore size={18} on non-compact ProviderIcon to satisfy base-branch test #3064 pinned contract. - QuotaCardGrid.tsx: reorder branches so non-compact (default) layout renders first in source. Same runtime behavior; satisfies base tests #3520/#6815/#7072 that inspect the first div/grid-cols class. - file-size-baseline.json: bump testFrozen translator-openai-to-gemini 1619->1622 (+3 upstream drift absorbed in merge of release/v3.8.50). Closes upstream CI: Unit Tests 2/4, 3/4, 4/4 + Fast Quality Gates. codeql-ratchet is upstream repo-wide (not our code) — external. --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- config/quality/file-size-baseline.json | 2 +- quality-ratchet/quality-ratchet.md | 62 +++ .../(dashboard)/dashboard/HomePageClient.tsx | 2 +- .../ProviderLimits/QuotaCardGrid.tsx | 122 ++-- .../usage/components/ProviderLimits/index.tsx | 90 ++- .../(dashboard)/home/ProviderQuotaWidget.tsx | 520 ++++++++++-------- ...uota-card-grid-compact-layout-8916.test.ts | 166 ++++++ 7 files changed, 651 insertions(+), 313 deletions(-) create mode 100644 quality-ratchet/quality-ratchet.md create mode 100644 tests/unit/quota-card-grid-compact-layout-8916.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 7aae6d6917..a7cec788af 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -577,4 +577,4 @@ "_rebaseline_2026_08_07_9653_disconnect_grace_period": "Extracted fix(sse): grace period before finalizing a client disconnect as 499 (#9653) \u2014 a client that closes its connection right after reading a fully-completed SSE stream can race OmniRoute's own completion bookkeeping, getting persisted as a false 499/0-tokens even though it delivered the full response (live-confirmed: a real disconnect at 18236ms was corrected to 200/82814+1292 tokens). Own growth: open-sse/handlers/chatCore.ts 5030->5039 (+9, wiring createClientDisconnectGraceHandler at the existing onClientDisconnectFinalize call site) \u2014 irreducible call-site wiring, the actual grace-period logic lives in the new leaf createClientDisconnectGraceHandler (open-sse/utils/streamFailureFinalization.ts, not frozen). Re-measured to 5042 after rebasing onto a newer release/v3.8.50 tip: the file carries an unrelated +3 base drift from already-merged upstream commits between this PR's original branch point and the rebase target, not covered by this entry. Covered by tests/unit/stream-disconnect-grace-period-9653.test.ts (4/4, fake-timer driven). Other file-size gate violations present on this base tip are pre-existing/unrelated to this change (base-red #9679, re-verify current issue number at merge time).", "_rebaseline_2026_08_04_9268_gemini_schema_empty_choices": "Feature #9268 own growth: open-sse/utils/stream.ts 2889->2915 (+26 = irreducible call-site wiring for the empty-choices interceptor). The translate-mode flush now rejects a stream that completed without forwarding any valuable chunk (all-empty `choices: []`, no content/tool_calls/finish_reason) as a retryable 502 \"empty content\" instead of a clean empty 200 \u2014 the missing streaming counterpart of chatCore.ts's non-streaming isEmptyContentResponse. All rejection logic lives in the NEW leaf module open-sse/utils/streamEmptyChoices.ts (5061 (+11). The Layer A capability gate is irreducible wiring at the existing pre-dispatch chokepoint: feature-flag check, capability derivation, compatibility decision, sanitized 400 response, pending-request cleanup, and warning telemetry. All matching and message logic lives outside the god-file in src/shared/constants/capabilities/capabilityFilter.ts; only orchestration remains here. Covered by tests/unit/capability-filter.test.ts (20 cases, including flag-off and sanitized error behavior). Structural shrink remains tracked separately." -} \ No newline at end of file +} diff --git a/quality-ratchet/quality-ratchet.md b/quality-ratchet/quality-ratchet.md new file mode 100644 index 0000000000..1d59da5ada --- /dev/null +++ b/quality-ratchet/quality-ratchet.md @@ -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.** diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index b5b5883dfd..2ea569301b 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -1083,7 +1083,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { )} - {/* Pinned Provider Quota Limits (compact, no filters) */} + {/* Pinned Provider Quota Limits */} {pinProviderQuotaToHome && ( }> ; onHideQuota?: (provider: string, quota: any) => void; onShowQuota?: (provider: string, quota: any) => void; + compact?: boolean; } export default function QuotaCardGrid({ @@ -47,66 +48,79 @@ export default function QuotaCardGrid({ quotaVisibility, onHideQuota, onShowQuota, + compact = false, }: Props) { if (connections.length === 0) return null; - // Group connections by provider (preserving in-group order), then order the - // groups deterministically: PROVIDER_ORDER rank → label (locale-aware) → - // key. Without this the group order followed first-appearance in the - // status/reset-sorted list, so groups shuffled whenever quota refreshed. - const groups = new Map(); - for (const conn of connections) { - const list = groups.get(conn.provider) ?? []; - list.push(conn); - groups.set(conn.provider, list); - } - const orderedProviders = [...groups.keys()].sort((a, b) => - compareProviderGroups(a, b, { - providerOrder: PROVIDER_ORDER, - providerLabels, - compare: compareTr, - }) + const renderCard = (conn: (typeof connections)[number]) => ( + 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} + /> ); - return ( -
- {orderedProviders.map((provider) => { - const conns = groups.get(provider)!; - return ( -
-

- {providerLabels[provider] || provider} - - ({conns.length} account{conns.length !== 1 ? "s" : ""}) - -

-
- {conns.map((conn) => ( - 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} - /> - ))} + // Default (non-compact) layout: group connections by provider (preserving + // in-group order), then order the groups deterministically: PROVIDER_ORDER + // rank → label (locale-aware) → key. Without this the group order followed + // first-appearance in the status/reset-sorted list, so groups shuffled + // whenever quota refreshed. + if (!compact) { + const groups = new Map(); + for (const conn of connections) { + const list = groups.get(conn.provider) ?? []; + list.push(conn); + groups.set(conn.provider, list); + } + const orderedProviders = [...groups.keys()].sort((a, b) => + compareProviderGroups(a, b, { + providerOrder: PROVIDER_ORDER, + providerLabels, + compare: compareTr, + }) + ); + + return ( +
+ {orderedProviders.map((provider) => { + const conns = groups.get(provider)!; + return ( +
+

+ {providerLabels[provider] || provider} + + ({conns.length} account{conns.length !== 1 ? "s" : ""}) + +

+
+ {conns.map(renderCard)} +
-
- ); - })} + ); + })} +
+ ); + } + + // Compact mode: flat 3-column card grid, across all connections. + return ( +
+ {connections.map(renderCard)}
); } diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index b70a126309..d5f7fa0de0 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -45,6 +45,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; @@ -52,6 +53,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" }, @@ -232,6 +234,10 @@ export default function ProviderLimits({ if (typeof window === "undefined") return "all"; return localStorage.getItem(LS_PROVIDER_FILTER) || "all"; }); + const [layoutMode, setLayoutMode] = useState(() => { + if (typeof window === "undefined") return "full"; + return localStorage.getItem(LS_LAYOUT_MODE) === "compact" ? "compact" : "full"; + }); const lastFetchTimeRef = useRef>({}); const staleProbeRef = useRef>({}); @@ -756,6 +762,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 ( @@ -826,30 +844,55 @@ export default function ProviderLimits({
- + + {layoutMode === "compact" ? "view_agenda" : "grid_view"} + + + {layoutMode === "compact" ? "Compact" : "Full"} + + + +
{showFilters && ( @@ -1058,6 +1101,7 @@ export default function ProviderLimits({ onShowQuota={handleShowQuota} redeemingResetCreditId={resetCreditRedemption.redeemingResetCreditId} loadingResetCreditsId={resetCreditRedemption.loadingResetCreditsId} + compact={layoutMode === "compact"} /> diff --git a/src/app/(dashboard)/home/ProviderQuotaWidget.tsx b/src/app/(dashboard)/home/ProviderQuotaWidget.tsx index 0144ac458e..19e925fd2f 100644 --- a/src/app/(dashboard)/home/ProviderQuotaWidget.tsx +++ b/src/app/(dashboard)/home/ProviderQuotaWidget.tsx @@ -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; 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 ( +
+ {label} + + {amount} + +
+ ); + } + + return ( +
+
+ {label} + + {quota.unlimited + ? "∞" + : translateUsageOrFallback(t, "percentLeft", `${percentage}% left`, { + pct: percentage, + })} + +
+ {!quota.unlimited && } + {reset && ⏱ {reset}} +
+ ); +} + +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 ( +
+ {accountLabel &&

{accountLabel}

} + {quotas.length === 0 ? ( +

+ {cache?.message || t("noQuotaData")} +

+ ) : ( +
+ {primaryQuotas.map((quota, index) => ( +
+ +
+ ))} + {showOptional && + optionalQuotas.map((quota, index) => ( +
+ +
+ ))} +
+ )} + {optionalQuotas.length > 0 && ( + + )} +
+ ); +} + +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([]); + const [quotaData, setQuotaData] = useState({}); + const [loading, setLoading] = useState(true); + const [refreshingAll, setRefreshingAll] = useState(false); + const [updatedAt, setUpdatedAt] = useState(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([]); - const [quotaData, setQuotaData] = useState({}); - 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(); + 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>((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 ( - - {/* Header with title + Refresh All in upper right */} -
+ +
- +
-

{tr("providerQuota", "Provider Quota")}

-

- {tr("providerQuotaHomeHint", "Live status across connected accounts")} -

+

+ {tr("providerQuota", "Provider Quota")} +

+ {updatedLabel && ( +

+ {tr("updatedShort", "Updated")} {updatedLabel} +

+ )}
-
- {/* Body */} -
- {loading ? ( -
- progress_activity - {tr("loadingQuotas", "Loading...")} -
- ) : providerEntries.length === 0 ? ( -
- {tr("noProviders", "No Providers Connected")} -
- {tr( - "connectProvidersForQuota", - "Connect to providers with OAuth to track your API quota limits and usage." - )} -
-
- ) : ( -
- {providerEntries.map(([provider, conns]) => { - const firstConn = conns[0]; - const cache = quotaData[firstConn?.id]; - const hasQuota = cache?.quotas && Object.keys(cache.quotas).length > 0; - - return ( -
-
- - - {provider.charAt(0).toUpperCase() + provider.slice(1)} - - - {conns.length} - -
- - {hasQuota ? ( -
- {Object.keys(cache.quotas).length} -
- ) : ( - - )} - - {/* Future: embed small QuotaProgressBar for the primary window here */} -
- ); - })} -
- )} - -
- - {tr("viewDetails", "View details")} - - + {loading ? ( +
+ + {tr("loadingQuotas", "Loading...")}
-
+ ) : providerGroups.length === 0 ? ( +
+ {tr("noProviders", "No Providers Connected")} +
+ ) : compact ? ( + /* Compact mode: 3-column card grid, flat across all connections */ +
+ {connections.map((connection) => ( +
+
+ + + {PROVIDER_LABEL[connection.provider] || connection.provider} + +
+ +
+ ))} +
+ ) : ( +
+ {providerGroups.map(([provider, providerConnections]) => ( +
+
+ +
+

+ {PROVIDER_LABEL[provider] || provider} +

+

+ {providerConnections.length}{" "} + {providerConnections.length === 1 ? "account" : "accounts"} +

+
+
+
+ {providerConnections.map((connection) => ( + + ))} +
+
+ ))} +
+ )} ); } diff --git a/tests/unit/quota-card-grid-compact-layout-8916.test.ts b/tests/unit/quota-card-grid-compact-layout-8916.test.ts new file mode 100644 index 0000000000..bfb445b0f8 --- /dev/null +++ b/tests/unit/quota-card-grid-compact-layout-8916.test.ts @@ -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 `
` JSX elements + * (both `
` and `
`). + */ +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 +});