diff --git a/package.json b/package.json
index 54f8835..1ba5cee 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "9router-app",
- "version": "0.3.24",
+ "version": "0.3.27",
"description": "9Router web dashboard",
"private": true,
"scripts": {
diff --git a/src/app/(dashboard)/dashboard/quota/page.js b/src/app/(dashboard)/dashboard/quota/page.js
new file mode 100644
index 0000000..ee5e7e7
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/quota/page.js
@@ -0,0 +1,11 @@
+import { Suspense } from "react";
+import { CardSkeleton } from "@/shared/components/Loading";
+import ProviderLimits from "../usage/components/ProviderLimits";
+
+export default function QuotaPage() {
+ return (
+ }>
+
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js
index d90261d..c9b5d12 100644
--- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js
+++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js
@@ -7,7 +7,6 @@ import QuotaTable from "./QuotaTable";
import { parseQuotaData, calculatePercentage } from "./utils";
import Card from "@/shared/components/Card";
import Button from "@/shared/components/Button";
-import { CardSkeleton } from "@/shared/components/Loading";
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
const REFRESH_INTERVAL_MS = 60000; // 60 seconds
@@ -21,7 +20,7 @@ export default function ProviderLimits() {
const [lastUpdated, setLastUpdated] = useState(null);
const [refreshingAll, setRefreshingAll] = useState(false);
const [countdown, setCountdown] = useState(60);
- const [initialLoading, setInitialLoading] = useState(true);
+ const [connectionsLoading, setConnectionsLoading] = useState(true);
const intervalRef = useRef(null);
const countdownRef = useRef(null);
@@ -142,12 +141,26 @@ export default function ProviderLimits() {
}
}, [refreshingAll, fetchConnections, fetchQuota]);
- // Initial load
+ // Initial load: fetch connections first so cards render immediately, then fetch quotas
useEffect(() => {
const initializeData = async () => {
- setInitialLoading(true);
- await refreshAll();
- setInitialLoading(false);
+ setConnectionsLoading(true);
+ const conns = await fetchConnections();
+ setConnectionsLoading(false);
+
+ const oauthConnections = conns.filter(
+ (conn) => USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
+ );
+
+ // Mark all as loading before fetching
+ const loadingState = {};
+ oauthConnections.forEach((conn) => { loadingState[conn.id] = true; });
+ setLoading(loadingState);
+
+ await Promise.all(
+ oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider))
+ );
+ setLastUpdated(new Date());
};
initializeData();
@@ -271,22 +284,8 @@ export default function ProviderLimits() {
return count + (hasLowQuota ? 1 : 0);
}, 0);
- // Initial loading state
- if (initialLoading) {
- return (
-
- );
- }
-
// Empty state
- if (sortedConnections.length === 0) {
+ if (!connectionsLoading && sortedConnections.length === 0) {
return (
diff --git a/src/app/(dashboard)/dashboard/usage/page.js b/src/app/(dashboard)/dashboard/usage/page.js
index e150571..3ee827f 100644
--- a/src/app/(dashboard)/dashboard/usage/page.js
+++ b/src/app/(dashboard)/dashboard/usage/page.js
@@ -3,7 +3,6 @@
import { Suspense, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { UsageStats, RequestLogger, CardSkeleton, SegmentedControl } from "@/shared/components";
-import ProviderLimits from "./components/ProviderLimits";
import RequestDetailsTab from "./components/RequestDetailsTab";
export default function UsagePage() {
@@ -21,7 +20,7 @@ function UsageContent() {
const [tabLoading, setTabLoading] = useState(false);
const tabFromUrl = searchParams.get("tab");
- const activeTab = tabFromUrl && ["overview", "logs", "limits", "details"].includes(tabFromUrl)
+ const activeTab = tabFromUrl && ["overview", "logs", "details"].includes(tabFromUrl)
? tabFromUrl
: "overview";
@@ -40,7 +39,6 @@ function UsageContent() {
)}
{activeTab === "logs" && }
- {activeTab === "limits" && (
- }>
-
-
- )}
{activeTab === "details" && }
>
)}
diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js
index 7e53315..ce68d9b 100644
--- a/src/shared/components/Sidebar.js
+++ b/src/shared/components/Sidebar.js
@@ -14,6 +14,7 @@ const navItems = [
{ href: "/dashboard/providers", label: "Providers", icon: "dns" },
{ href: "/dashboard/combos", label: "Combos", icon: "layers" },
{ href: "/dashboard/usage", label: "Usage", icon: "bar_chart" },
+ { href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" },
{ href: "/dashboard/cli-tools", label: "CLI Tools", icon: "terminal" },
];
diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js
index 89f918e..f08a1cf 100644
--- a/src/shared/components/UsageStats.js
+++ b/src/shared/components/UsageStats.js
@@ -2,7 +2,6 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation";
-import { CardSkeleton } from "./Loading";
import Badge from "./Badge";
import Card from "./Card";
import OverviewCards from "@/app/(dashboard)/dashboard/usage/components/OverviewCards";
@@ -348,27 +347,34 @@ export default function UsageStats() {
}
}, [stats, tableView, sortBy, sortOrder]);
- if (loading) return ;
- if (!stats) return Failed to load usage statistics.
;
+ if (!stats && !loading) return Failed to load usage statistics.
;
+
+ const spinner = (
+
+ progress_activity
+
+ );
return (
{/* Overview cards */}
-
+ {loading ? spinner :
}
{/* Provider topology + Recent Requests */}
-
+ {loading ? spinner : (
+
+ )}
{/* Token / Cost chart */}
-
+ {loading ? spinner :
}
{/* Table with dropdown selector */}
@@ -383,7 +389,7 @@ export default function UsageStats() {
))}
- {activeTableConfig && (
+ {loading ? spinner : activeTableConfig && (