- Add new "Quota Tracker" item to the sidebar navigation.

This commit is contained in:
decolua
2026-03-03 09:53:30 +07:00
parent 4e92a66379
commit bfd9614fa2
7 changed files with 127 additions and 68 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "9router-app", "name": "9router-app",
"version": "0.3.24", "version": "0.3.27",
"description": "9Router web dashboard", "description": "9Router web dashboard",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -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 (
<Suspense fallback={<CardSkeleton />}>
<ProviderLimits />
</Suspense>
);
}
@@ -7,7 +7,6 @@ import QuotaTable from "./QuotaTable";
import { parseQuotaData, calculatePercentage } from "./utils"; import { parseQuotaData, calculatePercentage } from "./utils";
import Card from "@/shared/components/Card"; import Card from "@/shared/components/Card";
import Button from "@/shared/components/Button"; import Button from "@/shared/components/Button";
import { CardSkeleton } from "@/shared/components/Loading";
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
const REFRESH_INTERVAL_MS = 60000; // 60 seconds const REFRESH_INTERVAL_MS = 60000; // 60 seconds
@@ -21,7 +20,7 @@ export default function ProviderLimits() {
const [lastUpdated, setLastUpdated] = useState(null); const [lastUpdated, setLastUpdated] = useState(null);
const [refreshingAll, setRefreshingAll] = useState(false); const [refreshingAll, setRefreshingAll] = useState(false);
const [countdown, setCountdown] = useState(60); const [countdown, setCountdown] = useState(60);
const [initialLoading, setInitialLoading] = useState(true); const [connectionsLoading, setConnectionsLoading] = useState(true);
const intervalRef = useRef(null); const intervalRef = useRef(null);
const countdownRef = useRef(null); const countdownRef = useRef(null);
@@ -142,12 +141,26 @@ export default function ProviderLimits() {
} }
}, [refreshingAll, fetchConnections, fetchQuota]); }, [refreshingAll, fetchConnections, fetchQuota]);
// Initial load // Initial load: fetch connections first so cards render immediately, then fetch quotas
useEffect(() => { useEffect(() => {
const initializeData = async () => { const initializeData = async () => {
setInitialLoading(true); setConnectionsLoading(true);
await refreshAll(); const conns = await fetchConnections();
setInitialLoading(false); 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(); initializeData();
@@ -271,22 +284,8 @@ export default function ProviderLimits() {
return count + (hasLowQuota ? 1 : 0); return count + (hasLowQuota ? 1 : 0);
}, 0); }, 0);
// Initial loading state
if (initialLoading) {
return (
<div className="space-y-4">
<CardSkeleton />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</div>
</div>
);
}
// Empty state // Empty state
if (sortedConnections.length === 0) { if (!connectionsLoading && sortedConnections.length === 0) {
return ( return (
<Card padding="lg"> <Card padding="lg">
<div className="text-center py-12"> <div className="text-center py-12">
+1 -8
View File
@@ -3,7 +3,6 @@
import { Suspense, useState } from "react"; import { Suspense, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { UsageStats, RequestLogger, CardSkeleton, SegmentedControl } from "@/shared/components"; import { UsageStats, RequestLogger, CardSkeleton, SegmentedControl } from "@/shared/components";
import ProviderLimits from "./components/ProviderLimits";
import RequestDetailsTab from "./components/RequestDetailsTab"; import RequestDetailsTab from "./components/RequestDetailsTab";
export default function UsagePage() { export default function UsagePage() {
@@ -21,7 +20,7 @@ function UsageContent() {
const [tabLoading, setTabLoading] = useState(false); const [tabLoading, setTabLoading] = useState(false);
const tabFromUrl = searchParams.get("tab"); const tabFromUrl = searchParams.get("tab");
const activeTab = tabFromUrl && ["overview", "logs", "limits", "details"].includes(tabFromUrl) const activeTab = tabFromUrl && ["overview", "logs", "details"].includes(tabFromUrl)
? tabFromUrl ? tabFromUrl
: "overview"; : "overview";
@@ -40,7 +39,6 @@ function UsageContent() {
<SegmentedControl <SegmentedControl
options={[ options={[
{ value: "overview", label: "Overview" }, { value: "overview", label: "Overview" },
{ value: "limits", label: "Limits" },
{ value: "details", label: "Details" }, { value: "details", label: "Details" },
]} ]}
value={activeTab} value={activeTab}
@@ -57,11 +55,6 @@ function UsageContent() {
</Suspense> </Suspense>
)} )}
{activeTab === "logs" && <RequestLogger />} {activeTab === "logs" && <RequestLogger />}
{activeTab === "limits" && (
<Suspense fallback={<CardSkeleton />}>
<ProviderLimits />
</Suspense>
)}
{activeTab === "details" && <RequestDetailsTab />} {activeTab === "details" && <RequestDetailsTab />}
</> </>
)} )}
+1
View File
@@ -14,6 +14,7 @@ const navItems = [
{ href: "/dashboard/providers", label: "Providers", icon: "dns" }, { href: "/dashboard/providers", label: "Providers", icon: "dns" },
{ href: "/dashboard/combos", label: "Combos", icon: "layers" }, { href: "/dashboard/combos", label: "Combos", icon: "layers" },
{ href: "/dashboard/usage", label: "Usage", icon: "bar_chart" }, { 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" }, { href: "/dashboard/cli-tools", label: "CLI Tools", icon: "terminal" },
]; ];
+21 -15
View File
@@ -2,7 +2,6 @@
import { useState, useEffect, useMemo, useCallback } from "react"; import { useState, useEffect, useMemo, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { CardSkeleton } from "./Loading";
import Badge from "./Badge"; import Badge from "./Badge";
import Card from "./Card"; import Card from "./Card";
import OverviewCards from "@/app/(dashboard)/dashboard/usage/components/OverviewCards"; import OverviewCards from "@/app/(dashboard)/dashboard/usage/components/OverviewCards";
@@ -348,27 +347,34 @@ export default function UsageStats() {
} }
}, [stats, tableView, sortBy, sortOrder]); }, [stats, tableView, sortBy, sortOrder]);
if (loading) return <CardSkeleton />; if (!stats && !loading) return <div className="text-text-muted">Failed to load usage statistics.</div>;
if (!stats) return <div className="text-text-muted">Failed to load usage statistics.</div>;
const spinner = (
<div className="flex items-center justify-center py-12 text-text-muted">
<span className="material-symbols-outlined text-[32px] animate-spin">progress_activity</span>
</div>
);
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Overview cards */} {/* Overview cards */}
<OverviewCards stats={stats} /> {loading ? spinner : <OverviewCards stats={stats} />}
{/* Provider topology + Recent Requests */} {/* Provider topology + Recent Requests */}
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-2 items-stretch"> {loading ? spinner : (
<ProviderTopology <div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-2 items-stretch">
providers={providers} <ProviderTopology
activeRequests={stats.activeRequests || []} providers={providers}
lastProvider={stats.recentRequests?.[0]?.provider || ""} activeRequests={stats.activeRequests || []}
errorProvider={stats.errorProvider || ""} lastProvider={stats.recentRequests?.[0]?.provider || ""}
/> errorProvider={stats.errorProvider || ""}
<RecentRequests requests={stats.recentRequests || []} /> />
</div> <RecentRequests requests={stats.recentRequests || []} />
</div>
)}
{/* Token / Cost chart */} {/* Token / Cost chart */}
<UsageChart /> {loading ? spinner : <UsageChart />}
{/* Table with dropdown selector */} {/* Table with dropdown selector */}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -383,7 +389,7 @@ export default function UsageStats() {
))} ))}
</select> </select>
</div> </div>
{activeTableConfig && ( {loading ? spinner : activeTableConfig && (
<UsageTable <UsageTable
title="" title=""
columns={activeTableConfig.columns} columns={activeTableConfig.columns}
+72 -23
View File
@@ -17,8 +17,8 @@ export const CLI_TOOLS = {
modelAliases: ["default", "sonnet", "opus", "haiku", "opusplan"], modelAliases: ["default", "sonnet", "opus", "haiku", "opusplan"],
settingsFile: "~/.claude/settings.json", settingsFile: "~/.claude/settings.json",
defaultModels: [ defaultModels: [
{ id: "opus", name: "Claude Opus", alias: "opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-5-20251101" }, { id: "opus", name: "Claude Opus", alias: "opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-6" },
{ id: "sonnet", name: "Claude Sonnet", alias: "sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-4-5-20250929" }, { id: "sonnet", name: "Claude Sonnet", alias: "sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-4-6" },
{ id: "haiku", name: "Claude Haiku", alias: "haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" }, { id: "haiku", name: "Claude Haiku", alias: "haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" },
], ],
}, },
@@ -30,6 +30,23 @@ export const CLI_TOOLS = {
description: "OpenAI Codex CLI", description: "OpenAI Codex CLI",
configType: "custom", configType: "custom",
}, },
antigravity: {
id: "antigravity",
name: "Antigravity",
image: "/providers/antigravity.png",
color: "#4285F4",
description: "Google Antigravity IDE with MITM",
configType: "mitm",
modelAliases: ["claude-opus-4-6-thinking", "claude-sonnet-4-6", "gemini-3-flash", "gpt-oss-120b-medium", "gemini-3-pro-high", "gemini-3-pro-low"],
defaultModels: [
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High", alias: "gemini-3.1-pro-high" },
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low", alias: "gemini-3.1-pro-low" },
{ id: "gemini-3-flash", name: "Gemini 3 Flash", alias: "gemini-3-flash" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", alias: "claude-sonnet-4-6" },
{ id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking", alias: "claude-opus-4-6-thinking" },
{ id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium", alias: "gpt-oss-120b-medium" },
],
},
droid: { droid: {
id: "droid", id: "droid",
name: "Factory Droid", name: "Factory Droid",
@@ -72,16 +89,65 @@ export const CLI_TOOLS = {
name: "Cline", name: "Cline",
image: "/providers/cline.png", image: "/providers/cline.png",
color: "#00D1B2", color: "#00D1B2",
description: "CLINE AI Assistant", description: "Cline AI Coding Assistant",
configType: "guide", configType: "guide",
guideSteps: [ guideSteps: [
{ step: 1, title: "Open Settings", desc: "Go to CLINE Settings panel" }, { step: 1, title: "Open Settings", desc: "Go to Cline Settings panel" },
{ step: 2, title: "Select Provider", desc: "Choose API Provider → Ollama" }, { step: 2, title: "Select Provider", desc: "Choose API Provider → OpenAI Compatible" },
{ step: 3, title: "Base URL", value: "{{baseUrl}}", copyable: true }, { step: 3, title: "Base URL", value: "{{baseUrl}}/v1", copyable: true },
{ step: 4, title: "API Key", type: "apiKeySelector" }, { step: 4, title: "API Key", type: "apiKeySelector" },
{ step: 5, title: "Select Model", type: "modelSelector" }, { step: 5, title: "Select Model", type: "modelSelector" },
], ],
}, },
kilo: {
id: "kilo",
name: "Kilo Code",
image: "/providers/kilocode.png",
color: "#FF6B6B",
description: "Kilo Code AI Assistant",
configType: "guide",
guideSteps: [
{ step: 1, title: "Open Settings", desc: "Go to Kilo Code Settings panel" },
{ step: 2, title: "Select Provider", desc: "Choose API Provider → OpenAI Compatible" },
{ step: 3, title: "Base URL", value: "{{baseUrl}}/v1", copyable: true },
{ step: 4, title: "API Key", type: "apiKeySelector" },
{ step: 5, title: "Select Model", type: "modelSelector" },
],
},
copilot: {
id: "copilot",
name: "GitHub Copilot",
image: "/providers/copilot.png",
color: "#1F6FEB",
description: "GitHub Copilot Chat — VS Code Extension",
configType: "guide",
guideSteps: [
{ step: 1, title: "Open VS Code Settings", desc: "Open Command Palette → \"Open User Settings (JSON)\"" },
{ step: 2, title: "Add config to chatLanguageModels.json", desc: "Add an entry using the Azure vendor pattern:" },
{ step: 3, title: "Base URL (endpoint)", value: "{{baseUrl}}/chat/completions#models.ai.azure.com", copyable: true },
{ step: 4, title: "API Key", type: "apiKeySelector" },
{ step: 5, title: "Select Model", type: "modelSelector" },
],
codeBlock: {
language: "json",
code: `{
"name": "9Router",
"vendor": "azure",
"apiKey": "{{apiKey}}",
"models": [
{
"id": "{{model}}",
"name": "{{model}}",
"url": "{{baseUrl}}/chat/completions#models.ai.azure.com",
"toolCalling": true,
"vision": false,
"maxInputTokens": 128000,
"maxOutputTokens": 16000
}
]
}`,
},
},
roo: { roo: {
id: "roo", id: "roo",
name: "Roo", name: "Roo",
@@ -121,23 +187,6 @@ export const CLI_TOOLS = {
}`, }`,
}, },
}, },
antigravity: {
id: "antigravity",
name: "Antigravity",
image: "/providers/antigravity.png",
color: "#4285F4",
description: "Google Antigravity IDE with MITM",
configType: "mitm",
modelAliases: ["claude-opus-4-6-thinking", "claude-sonnet-4-6", "gemini-3-flash", "gpt-oss-120b-medium", "gemini-3-pro-high", "gemini-3-pro-low"],
defaultModels: [
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High", alias: "gemini-3.1-pro-high" },
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low", alias: "gemini-3.1-pro-low" },
{ id: "gemini-3-flash", name: "Gemini 3 Flash", alias: "gemini-3-flash" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", alias: "claude-sonnet-4-6" },
{ id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking", alias: "claude-opus-4-6-thinking" },
{ id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium", alias: "gpt-oss-120b-medium" },
],
},
// HIDDEN: gemini-cli // HIDDEN: gemini-cli
// "gemini-cli": { // "gemini-cli": {
// id: "gemini-cli", // id: "gemini-cli",