[codex] home: restore settings-driven home layout and quota auto-refresh (#2800)

Integrated into release/v3.8.6
This commit is contained in:
Apostol Apostolov
2026-05-27 23:05:18 +03:00
committed by GitHub
parent f1e4c001a9
commit 87fad5d171
11 changed files with 487 additions and 159 deletions

View File

@@ -14,7 +14,7 @@ import { copyToClipboard } from "@/shared/utils/clipboard";
import { useIsElectron, useOpenExternal } from "@/shared/hooks/useElectron";
const ProviderTopology = dynamic(() => import("../home/ProviderTopology"), { ssr: false });
const ProviderLimits = dynamic(() => import("./usage/components/ProviderLimits"), { ssr: false });
const ProviderQuotaWidget = dynamic(() => import("../home/ProviderQuotaWidget"), { ssr: false });
import type { NewsAnnouncement } from "@/shared/utils/releaseNotes";
type UpdateStep = {
@@ -196,6 +196,8 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
const [pinProviderQuotaToHome, setPinProviderQuotaToHome] = useState(false);
const [showQuickStartOnHome, setShowQuickStartOnHome] = useState(true); // default on
const [showProviderTopologyOnHome, setShowProviderTopologyOnHome] = useState(true); // default on
const [autoRefreshProviderQuota, setAutoRefreshProviderQuota] = useState(false);
const [autoRefreshProviderQuotaInterval, setAutoRefreshProviderQuotaInterval] = useState(180);
useEffect(() => {
// Fetch the pin settings (lightweight)
@@ -212,6 +214,12 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
if (typeof data.showProviderTopologyOnHome === "boolean") {
setShowProviderTopologyOnHome(data.showProviderTopologyOnHome);
}
if (typeof data.autoRefreshProviderQuota === "boolean") {
setAutoRefreshProviderQuota(data.autoRefreshProviderQuota);
}
if (typeof data.autoRefreshProviderQuotaInterval === "number") {
setAutoRefreshProviderQuotaInterval(data.autoRefreshProviderQuotaInterval);
}
}
})
.catch(() => {
@@ -1066,7 +1074,11 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
{/* Pinned Provider Quota Limits (compact, no filters) */}
{pinProviderQuotaToHome && (
<Suspense fallback={<CardSkeleton />}>
<ProviderLimits showFilters={false} />
<ProviderQuotaWidget
autoRefreshInterval={
autoRefreshProviderQuota ? autoRefreshProviderQuotaInterval : 0
}
/>
</Suspense>
)}

View File

@@ -13,6 +13,7 @@ import {
normalizeComboConfigMode,
type ComboConfigMode,
} from "@/shared/constants/comboConfigMode";
import { PIN_PROVIDER_QUOTA_TO_HOME_KEY } from "@/shared/constants/homeWidgets";
export default function AppearanceTab() {
const { theme, setTheme, isDark } = useTheme();
@@ -34,6 +35,13 @@ export default function AppearanceTab() {
const isValidHex = /^#([0-9a-fA-F]{6})$/.test(
customThemeColor.startsWith("#") ? customThemeColor : `#${customThemeColor}`
);
const pinProviderQuotaToHome = settings.pinProviderQuotaToHome === true;
const showQuickStartOnHome = settings.showQuickStartOnHome !== false;
const showProviderTopologyOnHome = settings.showProviderTopologyOnHome !== false;
const autoRefreshProviderQuota = settings.autoRefreshProviderQuota === true;
const autoRefreshProviderQuotaInterval = Number.isFinite(settings.autoRefreshProviderQuotaInterval)
? Number(settings.autoRefreshProviderQuotaInterval)
: 180;
const comboConfigMode = normalizeComboConfigMode(settings[COMBO_CONFIG_MODE_SETTING_KEY]);
const showCloudflaredTunnel = settings.hideEndpointCloudflaredTunnel !== true;
const showTailscaleFunnel = settings.hideEndpointTailscaleFunnel !== true;
@@ -123,6 +131,10 @@ export default function AppearanceTab() {
},
];
const quotaRefreshInterval = Number.isFinite(autoRefreshProviderQuotaInterval)
? Math.min(3600, Math.max(10, Math.floor(autoRefreshProviderQuotaInterval)))
: 180;
return (
<Card>
<div className="flex items-center gap-3 mb-4">
@@ -170,6 +182,82 @@ export default function AppearanceTab() {
</div>
</div>
<div className="pt-4 border-t border-border">
<div className="mb-3">
<p className="font-medium">
{getSettingsLabel("homePinProviderQuotaToHome", "Pin Information to Home Page")}
</p>
<p className="text-sm text-text-muted">
Choose which sections to pin to the top of the Home page.
</p>
</div>
<div className="rounded-lg border border-border bg-surface/40 overflow-hidden">
<div className="divide-y divide-border/70">
<div className="flex items-start justify-between gap-4 px-4 py-3">
<div>
<p className="font-medium">
{getSettingsLabel("homeProviderQuotaLimits", "Provider Quota Limits")}
</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"homeProviderQuotaLimitsDesc",
"Pin the Provider Quota status container (with Refresh All button) to the top of the Home page."
)}
</p>
</div>
<Toggle
checked={pinProviderQuotaToHome}
onChange={async (checked) => {
await updateSetting(PIN_PROVIDER_QUOTA_TO_HOME_KEY, checked);
}}
disabled={loading}
/>
</div>
<div className="flex items-start justify-between gap-4 px-4 py-3">
<div>
<p className="font-medium">{getSettingsLabel("homeQuickStart", "Quick Start")}</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"homeQuickStartDesc",
"Show the Quick Start panel on the Home page."
)}
</p>
</div>
<Toggle
checked={showQuickStartOnHome}
onChange={async (checked) => {
await updateSetting("showQuickStartOnHome", checked);
}}
disabled={loading}
/>
</div>
<div className="flex items-start justify-between gap-4 px-4 py-3">
<div>
<p className="font-medium">
{getSettingsLabel("homeProviderTopology", "Provider Topology")}
</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"homeProviderTopologyDesc",
"Show the Provider Topology on the Home page."
)}
</p>
</div>
<Toggle
checked={showProviderTopologyOnHome}
onChange={async (checked) => {
await updateSetting("showProviderTopologyOnHome", checked);
}}
disabled={loading}
/>
</div>
</div>
</div>
</div>
<div className="pt-4 border-t border-border">
<p className="font-medium mb-1">{t("themeAccent")}</p>
<p className="text-sm text-text-muted mb-3">{t("themeAccentDesc")}</p>
@@ -348,6 +436,76 @@ export default function AppearanceTab() {
</div>
</div>
<div className="pt-4 border-t border-border">
<div className="mb-3">
<p className="font-medium">
{getSettingsLabel("providerQuotaAutoRefresh", "Provider Quota auto refresh")}
</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"providerQuotaAutoRefreshDesc",
"Refresh the Provider Limits view automatically while it stays open."
)}
</p>
</div>
<div className="rounded-lg border border-border bg-surface/40 divide-y divide-border/70">
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div>
<p className="font-medium">
{getSettingsLabel("providerQuotaAutoRefreshToggle", "Automatic refresh")}
</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"providerQuotaAutoRefreshToggleDesc",
"Refresh the quota view every few minutes while the page is visible."
)}
</p>
</div>
<Toggle
checked={autoRefreshProviderQuota}
onChange={async (checked) => {
if (checked && !settings.autoRefreshProviderQuotaInterval) {
await updateSetting("autoRefreshProviderQuotaInterval", 180);
}
await updateSetting("autoRefreshProviderQuota", checked);
}}
disabled={loading}
/>
</div>
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div>
<p className="font-medium">
{getSettingsLabel("providerQuotaAutoRefreshInterval", "Refresh interval")}
</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"providerQuotaAutoRefreshIntervalDesc",
"How often the quota view should refresh, in seconds."
)}
</p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={10}
max={3600}
step={10}
value={quotaRefreshInterval}
onChange={async (e) => {
const next = Math.min(3600, Math.max(10, Number(e.target.value) || 180));
await updateSetting("autoRefreshProviderQuotaInterval", next);
}}
disabled={loading || !autoRefreshProviderQuota}
className="h-10 w-28 px-3 rounded-lg bg-surface border border-border text-sm text-text-main focus:outline-none focus:border-primary disabled:opacity-50"
/>
<span className="text-xs text-text-muted">seconds</span>
</div>
</div>
</div>
</div>
<div className="pt-4 border-t border-border">
<div className="flex items-center justify-between gap-4">
<div>

View File

@@ -192,7 +192,22 @@ function aggregateWorst(statuses: StatusKey[]): "critical" | "alert" | "ok" | "e
return worst;
}
export default function ProviderLimits() {
function formatAutoRefreshCountdown(ms: number): string {
const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
}
interface ProviderLimitsProps {
showFilters?: boolean;
autoRefreshInterval?: number;
}
export default function ProviderLimits({
showFilters = true,
autoRefreshInterval = 0,
}: ProviderLimitsProps) {
const t = useTranslations("usage");
const tr = useCallback(
(key: string, fallback: string, values?: UsageTranslationValues) =>
@@ -228,6 +243,9 @@ export default function ProviderLimits() {
const lastFetchTimeRef = useRef<Record<string, number>>({});
const staleProbeRef = useRef<Record<string, number>>({});
const lastRefreshAllAtRef = useRef<number>(Date.now());
const autoRefreshIntervalMs = autoRefreshInterval > 0 ? autoRefreshInterval * 1000 : 0;
const [autoRefreshClock, setAutoRefreshClock] = useState(() => Date.now());
const [cutoffModalConn, setCutoffModalConn] = useState<any | null>(null);
const [cutoffModalWindows, setCutoffModalWindows] = useState<any[]>([]);
const [providerWindowDefaults, setProviderWindowDefaults] = useState<
@@ -401,6 +419,9 @@ export default function ProviderLimits() {
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" });
@@ -422,6 +443,33 @@ export default function ProviderLimits() {
}
}, [applyCachedQuotaState, fetchConnections]);
useEffect(() => {
if (autoRefreshIntervalMs <= 0) return;
const tick = () => setAutoRefreshClock(Date.now());
tick();
const timer = window.setInterval(tick, 1000);
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") tick();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.clearInterval(timer);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [autoRefreshIntervalMs]);
useEffect(() => {
if (autoRefreshIntervalMs <= 0) return;
if (document.visibilityState !== "visible") return;
if (refreshingAllRef.current) return;
if (autoRefreshClock - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) {
void refreshAll();
}
}, [autoRefreshClock, autoRefreshIntervalMs, refreshAll]);
useEffect(() => {
const init = async () => {
setInitialLoading(true);
@@ -702,148 +750,159 @@ export default function ProviderLimits() {
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" : ""}`}
>
refresh
{autoRefreshIntervalMs > 0 ? "schedule" : "refresh"}
</span>
{t("refreshAll")}
{refreshingAll
? tr("refreshing", "Refreshing")
: autoRefreshIntervalMs > 0
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
Math.max(0, autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current))
)}`
: t("refreshAll")}
</button>
</div>
{/* Summary stats — clickable status filter */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{(["all", "critical", "alert", "ok"] as StatusKey[]).map((key) => {
const tone = STATUS_TONE[key];
const labelMap: Record<string, string> = {
all: tr("statTotal", "Total"),
critical: tr("statCritical", "Critical"),
alert: tr("statAlert", "Alert"),
ok: tr("statHealthy", "Healthy"),
};
const active = statusFilter === key;
const count = statusCounts[key] || 0;
return (
<button
key={key}
type="button"
onClick={() => handleSetStatusFilter(key)}
className="text-left rounded-lg px-3 py-2.5 border transition-colors cursor-pointer"
style={{
background: active ? tone.bg : "var(--color-surface)",
borderColor: active ? tone.ring : "var(--color-border)",
}}
>
<div className="flex items-center justify-between">
<span className="text-[11px] uppercase tracking-wider font-semibold text-text-muted">
{labelMap[key]}
</span>
{key !== "all" && (
<span
className="w-1.5 h-1.5 rounded-full"
style={{ background: tone.dot }}
aria-hidden
/>
)}
</div>
<div
className="mt-0.5 text-2xl font-bold tabular-nums"
style={{ color: key === "all" ? "var(--color-text-main)" : tone.text }}
>
{count}
</div>
</button>
);
})}
</div>
{showFilters && (
<>
{/* Summary stats — clickable status filter */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{(["all", "critical", "alert", "ok"] as StatusKey[]).map((key) => {
const tone = STATUS_TONE[key];
const labelMap: Record<string, string> = {
all: tr("statTotal", "Total"),
critical: tr("statCritical", "Critical"),
alert: tr("statAlert", "Alert"),
ok: tr("statHealthy", "Healthy"),
};
const active = statusFilter === key;
const count = statusCounts[key] || 0;
return (
<button
key={key}
type="button"
onClick={() => handleSetStatusFilter(key)}
className="text-left rounded-lg px-3 py-2.5 border transition-colors cursor-pointer"
style={{
background: active ? tone.bg : "var(--color-surface)",
borderColor: active ? tone.ring : "var(--color-border)",
}}
>
<div className="flex items-center justify-between">
<span className="text-[11px] uppercase tracking-wider font-semibold text-text-muted">
{labelMap[key]}
</span>
{key !== "all" && (
<span
className="w-1.5 h-1.5 rounded-full"
style={{ background: tone.dot }}
aria-hidden
/>
)}
</div>
<div
className="mt-0.5 text-2xl font-bold tabular-nums"
style={{ color: key === "all" ? "var(--color-text-main)" : tone.text }}
>
{count}
</div>
</button>
);
})}
</div>
{/* Purchase Type filter */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] uppercase tracking-wider text-text-muted font-semibold mr-1">
{tr("filterPurchaseTypeLabel", "Type")}
</span>
{PURCHASE_TYPES.map((type) => {
const count = purchaseTypeCounts[type.key] || 0;
if (type.key !== "all" && count === 0) return null;
const active = purchaseTypeFilter === type.key;
return (
<button
key={type.key}
onClick={() => handleSetPurchaseFilter(type.key)}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold cursor-pointer"
style={{
border: active
? "1px solid var(--color-primary, #E54D5E)"
: "1px solid var(--color-border)",
background: active ? "rgba(229,77,94,0.1)" : "transparent",
color: active ? "var(--color-primary, #E54D5E)" : "var(--color-text-muted)",
}}
>
<span>{tr(type.labelKey, type.fallback)}</span>
<span className="opacity-85">{count}</span>
</button>
);
})}
</div>
{/* Purchase Type filter */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] uppercase tracking-wider text-text-muted font-semibold mr-1">
{tr("filterPurchaseTypeLabel", "Type")}
</span>
{PURCHASE_TYPES.map((type) => {
const count = purchaseTypeCounts[type.key] || 0;
if (type.key !== "all" && count === 0) return null;
const active = purchaseTypeFilter === type.key;
return (
<button
key={type.key}
onClick={() => handleSetPurchaseFilter(type.key)}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold cursor-pointer"
style={{
border: active
? "1px solid var(--color-primary, #E54D5E)"
: "1px solid var(--color-border)",
background: active ? "rgba(229,77,94,0.1)" : "transparent",
color: active ? "var(--color-primary, #E54D5E)" : "var(--color-text-muted)",
}}
>
<span>{tr(type.labelKey, type.fallback)}</span>
<span className="opacity-85">{count}</span>
</button>
);
})}
</div>
{/* Tier filter */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] uppercase tracking-wider text-text-muted font-semibold mr-1">
{tr("filterTierLabel", "Tier")}
</span>
{TIER_FILTERS.map((tier) => {
if (tier.key !== "all" && !tierCounts[tier.key]) return null;
const active = tierFilter === tier.key;
return (
<button
key={tier.key}
onClick={() => setTierFilter(tier.key)}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold cursor-pointer"
style={{
border: active
? "1px solid var(--color-primary, #E54D5E)"
: "1px solid var(--color-border)",
background: active ? "rgba(229,77,94,0.1)" : "transparent",
color: active ? "var(--color-primary, #E54D5E)" : "var(--color-text-muted)",
}}
>
<span>{tier.label || t(tier.labelKey!)}</span>
<span className="opacity-85">{tierCounts[tier.key] || 0}</span>
</button>
);
})}
</div>
{/* Tier filter */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] uppercase tracking-wider text-text-muted font-semibold mr-1">
{tr("filterTierLabel", "Tier")}
</span>
{TIER_FILTERS.map((tier) => {
if (tier.key !== "all" && !tierCounts[tier.key]) return null;
const active = tierFilter === tier.key;
return (
<button
key={tier.key}
onClick={() => setTierFilter(tier.key)}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold cursor-pointer"
style={{
border: active
? "1px solid var(--color-primary, #E54D5E)"
: "1px solid var(--color-border)",
background: active ? "rgba(229,77,94,0.1)" : "transparent",
color: active ? "var(--color-primary, #E54D5E)" : "var(--color-text-muted)",
}}
>
<span>{tier.label || t(tier.labelKey!)}</span>
<span className="opacity-85">{tierCounts[tier.key] || 0}</span>
</button>
);
})}
</div>
{/* Env filter — only renders when at least one connection has a tag */}
{envTags.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] uppercase tracking-wider text-text-muted font-semibold mr-1">
{tr("filterEnvLabel", "Env")}
</span>
{(["all", ...envTags] as string[]).map((tag) => {
const count = envCounts[tag] || 0;
const active = envFilter === tag;
const label = tag === "all" ? tr("filterEnvAll", "All") : tag;
return (
<button
key={tag}
onClick={() => handleSetEnvFilter(tag)}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold cursor-pointer"
style={{
border: active
? "1px solid var(--color-primary, #E54D5E)"
: "1px solid var(--color-border)",
background: active ? "rgba(229,77,94,0.1)" : "transparent",
color: active ? "var(--color-primary, #E54D5E)" : "var(--color-text-muted)",
}}
>
<span>{label}</span>
<span className="opacity-85">{count}</span>
</button>
);
})}
</div>
{/* Env filter — only renders when at least one connection has a tag */}
{envTags.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] uppercase tracking-wider text-text-muted font-semibold mr-1">
{tr("filterEnvLabel", "Env")}
</span>
{(["all", ...envTags] as string[]).map((tag) => {
const count = envCounts[tag] || 0;
const active = envFilter === tag;
const label = tag === "all" ? tr("filterEnvAll", "All") : tag;
return (
<button
key={tag}
onClick={() => handleSetEnvFilter(tag)}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold cursor-pointer"
style={{
border: active
? "1px solid var(--color-primary, #E54D5E)"
: "1px solid var(--color-border)",
background: active ? "rgba(229,77,94,0.1)" : "transparent",
color: active ? "var(--color-primary, #E54D5E)" : "var(--color-text-muted)",
}}
>
<span>{label}</span>
<span className="opacity-85">{count}</span>
</button>
);
})}
</div>
)}
</>
)}
{/* Provider groups */}

View File

@@ -5,6 +5,7 @@ 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 { translateUsageOrFallback } from "../dashboard/usage/components/ProviderLimits/i18nFallback";
type Connection = {
id: string;
@@ -16,9 +17,23 @@ type Connection = {
type QuotaData = Record<string, any>;
export default function ProviderQuotaWidget() {
interface ProviderQuotaWidgetProps {
autoRefreshInterval?: number;
}
function formatAutoRefreshCountdown(ms: number): string {
const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
}
export default function ProviderQuotaWidget({ autoRefreshInterval = 0 }: ProviderQuotaWidgetProps) {
const t = useTranslations("usage");
const tc = useTranslations("common");
const tr = useCallback(
(key: string, fallback: string) => translateUsageOrFallback(t, key, fallback),
[t]
);
const [connections, setConnections] = useState<Connection[]>([]);
const [quotaData, setQuotaData] = useState<QuotaData>({});
@@ -26,6 +41,9 @@ export default function ProviderQuotaWidget() {
const [refreshingAll, setRefreshingAll] = useState(false);
const refreshingAllRef = useRef(false);
const lastRefreshAllAtRef = useRef(Date.now());
const autoRefreshIntervalMs = autoRefreshInterval > 0 ? autoRefreshInterval * 1000 : 0;
const [autoRefreshClock, setAutoRefreshClock] = useState(() => Date.now());
const fetchConnections = useCallback(async () => {
try {
@@ -69,9 +87,30 @@ export default function ProviderQuotaWidget() {
loadData();
}, [loadData]);
useEffect(() => {
if (autoRefreshIntervalMs <= 0) return;
const tick = () => setAutoRefreshClock(Date.now());
tick();
const timer = window.setInterval(tick, 1000);
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") tick();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.clearInterval(timer);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [autoRefreshIntervalMs]);
const refreshAll = useCallback(async () => {
if (refreshingAllRef.current) return;
refreshingAllRef.current = true;
const now = Date.now();
lastRefreshAllAtRef.current = now;
setAutoRefreshClock(now);
setRefreshingAll(true);
try {
@@ -98,6 +137,15 @@ export default function ProviderQuotaWidget() {
}
}, [fetchConnections, fetchCached]);
useEffect(() => {
if (autoRefreshIntervalMs <= 0) return;
if (document.visibilityState !== "visible") return;
if (refreshingAllRef.current) return;
if (autoRefreshClock - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) {
void refreshAll();
}
}, [autoRefreshClock, 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] = [];
@@ -116,9 +164,9 @@ export default function ProviderQuotaWidget() {
account_balance
</span>
<div>
<h3 className="font-semibold text-base">{t("providerQuota") || "Provider Quota"}</h3>
<h3 className="font-semibold text-base">{tr("providerQuota", "Provider Quota")}</h3>
<p className="text-[11px] text-text-muted -mt-0.5">
{t("providerQuotaHomeHint") || "Live status across connected accounts"}
{tr("providerQuotaHomeHint", "Live status across connected accounts")}
</p>
</div>
</div>
@@ -127,14 +175,22 @@ export default function ProviderQuotaWidget() {
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={t("refreshAll") || "Refresh All"}
title={autoRefreshIntervalMs > 0 ? tr("autoRefreshing", "Auto-refreshing") : tr("refreshAll", "Refresh All")}
>
<span
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
>
refresh
{autoRefreshIntervalMs > 0 ? "schedule" : "refresh"}
</span>
<span>
{refreshingAll
? tr("refreshing", "Refreshing")
: autoRefreshIntervalMs > 0
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
Math.max(0, autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current))
)}`
: tr("refreshAll", "Refresh All")}
</span>
<span>{t("refreshAll") || "Refresh All"}</span>
</button>
</div>
@@ -143,13 +199,16 @@ export default function ProviderQuotaWidget() {
{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>
Loading quota information...
{tr("loadingQuotas", "Loading...")}
</div>
) : providerEntries.length === 0 ? (
<div className="text-center py-6 text-sm text-text-muted">
No quota-supported providers connected yet.
{tr("noProviders", "No Providers Connected")}
<div className="mt-1 text-xs">
Add accounts on the Providers page to see quota status here.
{tr(
"connectProvidersForQuota",
"Connect to providers with OAuth to track your API quota limits and usage."
)}
</div>
</div>
) : (
@@ -169,19 +228,23 @@ export default function ProviderQuotaWidget() {
<span className="font-medium text-sm truncate">
{provider.charAt(0).toUpperCase() + provider.slice(1)}
</span>
<span className="text-[10px] text-text-muted ml-auto">
{conns.length} account{conns.length > 1 ? "s" : ""}
<span className="text-[10px] text-text-muted ml-auto tabular-nums">
{conns.length}
</span>
</div>
{hasQuota ? (
<div className="text-xs text-text-muted">
{Object.keys(cache.quotas).length} quota window(s) tracked
<div className="text-xs text-text-muted" title={tr("details", "Details")}>
{Object.keys(cache.quotas).length}
</div>
) : (
<div className="text-xs text-amber-600 dark:text-amber-500">
No quota data yet click Refresh All
</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 */}
@@ -193,7 +256,8 @@ export default function ProviderQuotaWidget() {
<div className="mt-3 text-[11px] text-right text-text-muted">
<a href="/dashboard/usage?tab=limits" className="hover:text-primary hover:underline">
View full Provider Quota page
{tr("viewDetails", "View details")}
<span aria-hidden="true"> &rarr;</span>
</a>
</div>
</div>

View File

@@ -4355,6 +4355,13 @@
"modelsDevInfoOrder": "User Override → models.dev → LiteLLM → Hardcoded Default",
"systemTheme": "System Theme",
"debugToggle": "Enable Debug Mode",
"homePinProviderQuotaToHome": "Pin Information to Home Page",
"homeProviderQuotaLimits": "Provider Quota Limits",
"homeProviderQuotaLimitsDesc": "Pin the Provider Quota status container (with Refresh All button) to the top of the Home page.",
"homeQuickStart": "Quick Start",
"homeQuickStartDesc": "Show the Quick Start panel on the Home page.",
"homeProviderTopology": "Provider Topology",
"homeProviderTopologyDesc": "Show the Provider Topology on the Home page.",
"sidebarVisibilityToggle": "Show Sidebar Items",
"enableCache": "Enable Cache",
"cacheTTL": "Cache TTL",

View File

@@ -99,6 +99,8 @@ export async function getSettings() {
hideEndpointCloudflaredTunnel: false,
hideEndpointTailscaleFunnel: false,
hideEndpointNgrokTunnel: false,
autoRefreshProviderQuota: false,
autoRefreshProviderQuotaInterval: 180,
comboConfigMode: "guided",
codexServiceTier: { enabled: false },
claudeFastMode: { enabled: false, supportedModels: ["claude-opus-4-7", "claude-opus-4-6"] },

View File

@@ -34,6 +34,8 @@ export const updateSettingsSchema = z.object({
hideEndpointCloudflaredTunnel: z.boolean().optional(),
hideEndpointTailscaleFunnel: z.boolean().optional(),
hideEndpointNgrokTunnel: z.boolean().optional(),
autoRefreshProviderQuota: z.boolean().optional(),
autoRefreshProviderQuotaInterval: z.number().int().min(10).max(3600).optional(),
debugMode: z.boolean().optional(),
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
sidebarSectionOrder: z

View File

@@ -1148,6 +1148,10 @@ async function handleSingleModelChat(
// Daily quota lockout overrides subsequent rate_limited lockout, ensuring lockout until tomorrow 0:00
let dailyQuotaExhausted = false;
const errorStr = String(result.error || "");
const failureKind =
result.status === 429
? classify429FromError({ status: result.status, message: errorStr })
: undefined;
if (result.status === 429 && isDailyQuotaExhausted(errorStr)) {
// Parse which model is quota-limited
const match = errorStr.match(/today's quota for model ([^,]+)/);
@@ -1182,10 +1186,6 @@ async function handleSingleModelChat(
// quotaCache as exhausted for 5 minutes while usage quota may still be available.
if (!dailyQuotaExhausted) {
const passthroughModels = credentials.providerSpecificData?.passthroughModels;
const failureKind =
result.status === 429
? classify429FromError({ status: result.status, message: errorStr })
: undefined;
if (
result.status === 429 &&
shouldMarkAccountExhaustedFrom429(provider, model, passthroughModels, failureKind)
@@ -1212,7 +1212,11 @@ async function handleSingleModelChat(
result.error,
provider,
model,
providerProfile
providerProfile,
{
persistUnavailableState:
!(isCombo && result.status === 429 && (failureKind === "rate_limit" || failureKind === "transient")),
}
);
if (shouldFallback) {

View File

@@ -1558,7 +1558,10 @@ export async function markAccountUnavailable(
errorText: string,
provider: string | null = null,
model: string | null = null,
providerProfile = null
providerProfile = null,
options: {
persistUnavailableState?: boolean;
} = {}
) {
const currentMutex = markMutexes.get(connectionId) || Promise.resolve();
let resolveMutex: (() => void) | undefined;
@@ -1782,8 +1785,13 @@ export async function markAccountUnavailable(
lastErrorAt: new Date().toISOString(),
backoffLevel: newBackoffLevel ?? backoffLevel,
};
const persistUnavailableState = options.persistUnavailableState !== false;
if (cooldownMs > 0) {
if (!persistUnavailableState) {
await updateProviderConnection(connectionId, {
...baseUpdate,
});
} else if (cooldownMs > 0) {
await updateProviderConnection(connectionId, {
...baseUpdate,
rateLimitedUntil: getUnavailableUntil(cooldownMs),

View File

@@ -29,6 +29,8 @@ export interface Settings {
hideEndpointCloudflaredTunnel?: boolean;
hideEndpointTailscaleFunnel?: boolean;
hideEndpointNgrokTunnel?: boolean;
autoRefreshProviderQuota?: boolean;
autoRefreshProviderQuotaInterval?: number;
pinProviderQuotaToHome?: boolean;
showQuickStartOnHome?: boolean;
showProviderTopologyOnHome?: boolean;

View File

@@ -6,6 +6,7 @@ import path from "node:path";
const providerLimitUtils =
await import("../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx");
const providerConstants = await import("../../src/shared/constants/providers.ts");
const settingsSchemas = await import("../../src/shared/validation/settingsSchemas.ts");
test("provider plan fallbacks normalize to Unknown instead of repeating provider labels", () => {
const tier = providerLimitUtils.normalizePlanTier("Claude Code");
@@ -223,3 +224,12 @@ test("usage namespace includes Provider Limits UI translation keys", () => {
assert.ok(!usage[key].startsWith("__MISSING__:"), `usage.${key} should not be a placeholder`);
}
});
test("provider quota auto-refresh settings are accepted by the settings schema", () => {
const result = settingsSchemas.updateSettingsSchema.safeParse({
autoRefreshProviderQuota: true,
autoRefreshProviderQuotaInterval: 180,
});
assert.equal(result.success, true);
});