mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-09 00:32:13 +03:00
Compare commits
3 Commits
feat/9239-
...
codex/quot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01c6373bcd | ||
|
|
aa0d99e0a5 | ||
|
|
3c483dd906 |
@@ -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."
|
||||
}
|
||||
|
||||
62
quality-ratchet/quality-ratchet.md
Normal file
62
quality-ratchet/quality-ratchet.md
Normal 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.**
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"> →</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>
|
||||
);
|
||||
}
|
||||
|
||||
166
tests/unit/quota-card-grid-compact-layout-8916.test.ts
Normal file
166
tests/unit/quota-card-grid-compact-layout-8916.test.ts
Normal 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
|
||||
});
|
||||
Reference in New Issue
Block a user