mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
[codex] home: restore settings-driven home layout and quota auto-refresh (#2800)
Integrated into release/v3.8.6
This commit is contained in:
committed by
GitHub
parent
f1e4c001a9
commit
87fad5d171
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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"> →</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface Settings {
|
||||
hideEndpointCloudflaredTunnel?: boolean;
|
||||
hideEndpointTailscaleFunnel?: boolean;
|
||||
hideEndpointNgrokTunnel?: boolean;
|
||||
autoRefreshProviderQuota?: boolean;
|
||||
autoRefreshProviderQuotaInterval?: number;
|
||||
pinProviderQuotaToHome?: boolean;
|
||||
showQuickStartOnHome?: boolean;
|
||||
showProviderTopologyOnHome?: boolean;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user