Fix : usage convert

This commit is contained in:
decolua
2026-02-04 09:54:11 +07:00
parent 7881db81ec
commit e6e44ac364
10 changed files with 974 additions and 16 deletions
@@ -0,0 +1,187 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import Card from "@/shared/components/Card";
import Badge from "@/shared/components/Badge";
import QuotaProgressBar from "./QuotaProgressBar";
import { calculatePercentage } from "./utils";
const planVariants = {
free: "default",
pro: "primary",
ultra: "success",
enterprise: "info",
};
export default function ProviderLimitCard({
provider,
name,
plan,
quotas = [],
message = null,
loading = false,
error = null,
onRefresh,
}) {
const [refreshing, setRefreshing] = useState(false);
const [imgError, setImgError] = useState(false);
const handleRefresh = async () => {
if (!onRefresh || refreshing) return;
setRefreshing(true);
try {
await onRefresh();
} finally {
setRefreshing(false);
}
};
// Get provider info from config
const getProviderColor = () => {
const colors = {
github: "#000000",
antigravity: "#4285F4",
codex: "#10A37F",
kiro: "#FF9900",
claude: "#D97757",
};
return colors[provider?.toLowerCase()] || "#6B7280";
};
const providerColor = getProviderColor();
const planVariant = planVariants[plan?.toLowerCase()] || "default";
return (
<Card padding="md" className="flex flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{/* Provider Logo */}
<div
className="size-10 rounded-lg flex items-center justify-center p-1.5"
style={{ backgroundColor: `${providerColor}15` }}
>
{imgError ? (
<span
className="text-sm font-bold"
style={{ color: providerColor }}
>
{provider?.slice(0, 2).toUpperCase() || "PR"}
</span>
) : (
<Image
src={`/providers/${provider}.png`}
alt={provider || "Provider"}
width={40}
height={40}
className="object-contain rounded-lg"
sizes="40px"
onError={() => setImgError(true)}
/>
)}
</div>
<div>
<h3 className="font-semibold text-text-primary">{name || provider}</h3>
{plan && (
<Badge
variant={planVariants[plan?.toLowerCase()] || "default"}
size="xs"
>
{plan}
</Badge>
)}
</div>
</div>
{/* Refresh Button */}
<button
onClick={handleRefresh}
disabled={refreshing || loading}
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Refresh quota"
>
<span
className={`material-symbols-outlined text-[20px] text-text-muted ${
refreshing || loading ? "animate-spin" : ""
}`}
>
refresh
</span>
</button>
</div>
{/* Loading State */}
{loading && (
<div className="space-y-4">
<div className="space-y-2">
<div className="h-4 bg-black/5 dark:bg-white/5 rounded animate-pulse" />
<div className="h-2 bg-black/5 dark:bg-white/5 rounded animate-pulse" />
</div>
<div className="space-y-2">
<div className="h-4 bg-black/5 dark:bg-white/5 rounded animate-pulse" />
<div className="h-2 bg-black/5 dark:bg-white/5 rounded animate-pulse" />
</div>
</div>
)}
{/* Error State */}
{!loading && error && (
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/20">
<div className="flex items-start gap-2">
<span className="material-symbols-outlined text-red-500 text-[20px]">
error
</span>
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
</div>
)}
{/* Info Message (for providers without API) */}
{!loading && !error && message && (
<div className="p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<div className="flex items-start gap-2">
<span className="material-symbols-outlined text-blue-500 text-[20px]">
info
</span>
<p className="text-sm text-blue-600 dark:text-blue-400">{message}</p>
</div>
</div>
)}
{/* Quota Progress Bars */}
{!loading && !error && !message && quotas?.length > 0 && (
<div className="space-y-4">
{quotas.map((quota, index) => {
const percentage = calculatePercentage(quota.used, quota.total);
const unlimited = quota.total === 0 || quota.total === null;
return (
<QuotaProgressBar
key={`${quota.name}-${index}`}
label={quota.name}
used={quota.used}
total={quota.total}
percentage={percentage}
unlimited={unlimited}
resetTime={quota.resetAt}
/>
);
})}
</div>
)}
{/* Empty State */}
{!loading && !error && !message && quotas?.length === 0 && (
<div className="text-center py-8 text-text-muted">
<span className="material-symbols-outlined text-[48px] opacity-20">
data_usage
</span>
<p className="text-sm mt-2">No quota data available</p>
</div>
)}
</Card>
);
}
@@ -0,0 +1,105 @@
"use client";
import { cn } from "@/shared/utils/cn";
// Helper function to calculate time until reset
const getResetTimeText = (resetTime) => {
if (!resetTime) return null;
const now = new Date();
const reset = new Date(resetTime);
const diffMs = reset - now;
if (diffMs <= 0) return "Reset now";
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) {
return `Reset in ${hours}h`;
}
return `Reset in ${minutes}m`;
};
// Calculate color based on remaining percentage
const getColorClasses = (percentage) => {
if (percentage === 0) {
return {
text: "text-gray-400",
bg: "bg-gray-400",
bgLight: "bg-gray-400/10"
};
}
const remaining = 100 - percentage;
if (remaining > 70) {
return {
text: "text-green-500",
bg: "bg-green-500",
bgLight: "bg-green-500/10"
};
}
if (remaining >= 30) {
return {
text: "text-yellow-500",
bg: "bg-yellow-500",
bgLight: "bg-yellow-500/10"
};
}
return {
text: "text-red-500",
bg: "bg-red-500",
bgLight: "bg-red-500/10"
};
};
export default function QuotaProgressBar({
percentage = 0,
label = "",
used = 0,
total = 0,
unlimited = false,
resetTime = null
}) {
const colors = getColorClasses(percentage);
const resetText = getResetTimeText(resetTime);
return (
<div className="space-y-2">
{/* Label and usage info */}
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-text-primary dark:text-white">
{label}
</span>
<div className="flex items-center gap-2 text-text-muted">
{unlimited ? (
<span>Unlimited</span>
) : (
<span>
{used.toLocaleString()}/{total.toLocaleString()} ({percentage}%)
</span>
)}
{resetText && (
<>
<span></span>
<span className="text-xs">{resetText}</span>
</>
)}
</div>
</div>
{/* Progress bar */}
{!unlimited && (
<div className={cn("h-2 rounded-full overflow-hidden", colors.bgLight)}>
<div
className={cn("h-full transition-all duration-300", colors.bg)}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,328 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import ProviderLimitCard from "./ProviderLimitCard";
import { parseQuotaData, calculatePercentage } from "./utils";
import Card from "@/shared/components/Card";
import Button from "@/shared/components/Button";
import { CardSkeleton } from "@/shared/components/Loading";
const REFRESH_INTERVAL_MS = 60000; // 60 seconds
export default function ProviderLimits() {
const [connections, setConnections] = useState([]);
const [quotaData, setQuotaData] = useState({});
const [loading, setLoading] = useState({});
const [errors, setErrors] = useState({});
const [autoRefresh, setAutoRefresh] = useState(true);
const [lastUpdated, setLastUpdated] = useState(null);
const [refreshingAll, setRefreshingAll] = useState(false);
const [countdown, setCountdown] = useState(60);
const [initialLoading, setInitialLoading] = useState(true);
const intervalRef = useRef(null);
const countdownRef = useRef(null);
// Fetch all provider connections
const fetchConnections = useCallback(async () => {
try {
const response = await fetch("/api/providers/client");
if (!response.ok) throw new Error("Failed to fetch connections");
const data = await response.json();
const connectionList = data.connections || [];
setConnections(connectionList);
return connectionList;
} catch (error) {
console.error("Error fetching connections:", error);
setConnections([]);
return [];
}
}, []);
// Fetch quota for a specific connection
const fetchQuota = useCallback(async (connectionId, provider) => {
setLoading((prev) => ({ ...prev, [connectionId]: true }));
setErrors((prev) => ({ ...prev, [connectionId]: null }));
try {
console.log(`[ProviderLimits] Fetching quota for ${provider} (${connectionId})`);
const response = await fetch(`/api/usage/${connectionId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log(`[ProviderLimits] Got quota for ${provider}:`, data);
// Parse quota data using provider-specific parser
const parsedQuotas = parseQuotaData(provider, data);
setQuotaData((prev) => ({
...prev,
[connectionId]: {
quotas: parsedQuotas,
plan: data.plan || null,
message: data.message || null,
raw: data,
},
}));
} catch (error) {
console.error(`[ProviderLimits] Error fetching quota for ${provider} (${connectionId}):`, error);
setErrors((prev) => ({
...prev,
[connectionId]: error.message || "Failed to fetch quota",
}));
} finally {
setLoading((prev) => ({ ...prev, [connectionId]: false }));
}
}, []);
// Refresh quota for a specific provider
const refreshProvider = useCallback(
async (connectionId, provider) => {
await fetchQuota(connectionId, provider);
setLastUpdated(new Date());
},
[fetchQuota]
);
// Refresh all providers
const refreshAll = useCallback(async () => {
if (refreshingAll) return;
setRefreshingAll(true);
setCountdown(60);
try {
const conns = await fetchConnections();
// Fetch quota for all connections (filter by provider support in parseQuotaData)
await Promise.all(
conns.map((conn) => fetchQuota(conn.id, conn.provider))
);
setLastUpdated(new Date());
} catch (error) {
console.error("Error refreshing all providers:", error);
} finally {
setRefreshingAll(false);
}
}, [refreshingAll, fetchConnections, fetchQuota]);
// Initial load
useEffect(() => {
const initializeData = async () => {
setInitialLoading(true);
await refreshAll();
setInitialLoading(false);
};
initializeData();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Auto-refresh interval
useEffect(() => {
if (!autoRefresh) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
return;
}
// Main refresh interval
intervalRef.current = setInterval(() => {
refreshAll();
}, REFRESH_INTERVAL_MS);
// Countdown interval
countdownRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) return 60;
return prev - 1;
});
}, 1000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
if (countdownRef.current) clearInterval(countdownRef.current);
};
}, [autoRefresh, refreshAll]);
// Pause auto-refresh when tab is hidden (Page Visibility API)
useEffect(() => {
const handleVisibilityChange = () => {
if (document.hidden) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
} else if (autoRefresh) {
// Resume auto-refresh when tab becomes visible
intervalRef.current = setInterval(refreshAll, REFRESH_INTERVAL_MS);
countdownRef.current = setInterval(() => {
setCountdown((prev) => (prev <= 1 ? 60 : prev - 1));
}, 1000);
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [autoRefresh, refreshAll]);
// Format last updated time
const formatLastUpdated = useCallback(() => {
if (!lastUpdated) return "Never";
const now = new Date();
const diffMs = now - lastUpdated;
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `${diffDays}d ago`;
if (diffHours > 0) return `${diffHours}h ago`;
if (diffMinutes > 0) return `${diffMinutes}m ago`;
return "Just now";
}, [lastUpdated]);
// Filter only supported providers
const supportedProviders = ["antigravity", "kiro", "github", "claude"];
const filteredConnections = connections.filter((conn) =>
supportedProviders.includes(conn.provider)
);
// Calculate summary stats
const totalProviders = filteredConnections.length;
const activeWithLimits = Object.values(quotaData).filter(
(data) => data?.quotas?.length > 0
).length;
// Count low quotas (remaining < 30%)
const lowQuotasCount = Object.values(quotaData).reduce((count, data) => {
if (!data?.quotas) return count;
const hasLowQuota = data.quotas.some((quota) => {
const percentage = calculatePercentage(quota.used, quota.total);
return percentage < 30 && quota.total > 0;
});
return count + (hasLowQuota ? 1 : 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
if (filteredConnections.length === 0) {
return (
<Card padding="lg">
<div className="text-center py-12">
<span className="material-symbols-outlined text-[64px] text-text-muted opacity-20">
cloud_off
</span>
<h3 className="mt-4 text-lg font-semibold text-text-primary">
No Providers Connected
</h3>
<p className="mt-2 text-sm text-text-muted max-w-md mx-auto">
Connect to providers with OAuth to track your API quota limits and usage.
</p>
</div>
</Card>
);
}
return (
<div className="space-y-6">
{/* Header Controls */}
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold text-text-primary">
Provider Limits
</h2>
<span className="text-sm text-text-muted">
Last updated: {formatLastUpdated()}
</span>
</div>
<div className="flex items-center gap-2">
{/* Auto-refresh toggle */}
<button
onClick={() => setAutoRefresh((prev) => !prev)}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
title={autoRefresh ? "Disable auto-refresh" : "Enable auto-refresh"}
>
<span
className={`material-symbols-outlined text-[18px] ${
autoRefresh ? "text-primary" : "text-text-muted"
}`}
>
{autoRefresh ? "toggle_on" : "toggle_off"}
</span>
<span className="text-sm text-text-primary">Auto-refresh</span>
{autoRefresh && (
<span className="text-xs text-text-muted">({countdown}s)</span>
)}
</button>
{/* Refresh all button */}
<Button
variant="secondary"
size="md"
icon="refresh"
onClick={refreshAll}
disabled={refreshingAll}
loading={refreshingAll}
>
Refresh All
</Button>
</div>
</div>
{/* Provider Cards Grid */}
<div className="flex flex-col gap-4">
{filteredConnections.map((conn) => {
const quota = quotaData[conn.id];
const isLoading = loading[conn.id];
const error = errors[conn.id];
return (
<ProviderLimitCard
key={conn.id}
provider={conn.provider}
name={conn.name}
plan={quota?.plan}
quotas={quota?.quotas}
message={quota?.message}
loading={isLoading}
error={error}
onRefresh={() => refreshProvider(conn.id, conn.provider)}
/>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,188 @@
/**
* Format ISO date string to countdown format
* @param {string|Date} date - ISO date string or Date object
* @returns {string} Formatted countdown (e.g., "5d 12h", "2h 30m", "15m") or "-"
*/
export function formatResetTime(date) {
if (!date) return "-";
try {
const resetDate = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diffMs = resetDate - now;
if (diffMs <= 0) return "-";
const totalMinutes = Math.floor(diffMs / (1000 * 60));
const totalHours = Math.floor(totalMinutes / 60);
const totalDays = Math.floor(totalHours / 24);
const days = totalDays;
const hours = totalHours % 24;
const minutes = totalMinutes % 60;
if (days > 0) {
return `${days}d ${hours}h`;
}
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
} catch (error) {
return "-";
}
}
/**
* Get Tailwind color class based on percentage
* @param {number} percentage - Remaining percentage (0-100)
* @returns {string} Color name: "green" | "yellow" | "red" | "gray"
*/
export function getStatusColor(percentage) {
if (percentage === 0 || percentage === null || percentage === undefined) {
return "gray";
}
if (percentage > 70) return "green";
if (percentage >= 30) return "yellow";
return "red";
}
/**
* Get status emoji based on percentage
* @param {number} percentage - Remaining percentage (0-100)
* @returns {string} Emoji: "🟢" | "🟡" | "🔴" | "⚫"
*/
export function getStatusEmoji(percentage) {
if (percentage === 0 || percentage === null || percentage === undefined) {
return "⚫";
}
if (percentage > 70) return "🟢";
if (percentage >= 30) return "🟡";
return "🔴";
}
/**
* Calculate remaining percentage
* @param {number} used - Used amount
* @param {number} total - Total amount
* @returns {number} Remaining percentage (0-100)
*/
export function calculatePercentage(used, total) {
if (!total || total === 0) return 0;
if (!used || used < 0) return 100;
if (used >= total) return 0;
return Math.round(((total - used) / total) * 100);
}
/**
* Parse provider-specific quota structures into normalized array
* @param {string} provider - Provider name (github, antigravity, codex, kiro, claude)
* @param {Object} data - Raw quota data from provider
* @returns {Array<Object>} Normalized quota objects with { name, used, total, resetAt }
*/
export function parseQuotaData(provider, data) {
if (!data || typeof data !== "object") return [];
const normalizedQuotas = [];
try {
switch (provider.toLowerCase()) {
case "github":
if (data.quotas) {
Object.entries(data.quotas).forEach(([name, quota]) => {
normalizedQuotas.push({
name,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
});
});
}
break;
case "antigravity":
if (data.quotas) {
Object.entries(data.quotas).forEach(([modelName, quota]) => {
normalizedQuotas.push({
name: modelName,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
});
});
}
break;
case "codex":
if (data.quotas) {
Object.entries(data.quotas).forEach(([quotaType, quota]) => {
normalizedQuotas.push({
name: quotaType,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
});
});
}
break;
case "kiro":
if (data.quotas) {
Object.entries(data.quotas).forEach(([quotaType, quota]) => {
normalizedQuotas.push({
name: quotaType,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
});
});
}
break;
case "claude":
if (data.message) {
// Handle error message case
normalizedQuotas.push({
name: "error",
used: 0,
total: 0,
resetAt: null,
message: data.message,
});
} else if (data.quotas) {
Object.entries(data.quotas).forEach(([name, quota]) => {
normalizedQuotas.push({
name,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
});
});
}
break;
default:
// Generic fallback for unknown providers
if (data.quotas) {
Object.entries(data.quotas).forEach(([name, quota]) => {
normalizedQuotas.push({
name,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
});
});
}
}
} catch (error) {
console.error(`Error parsing quota data for ${provider}:`, error);
return [];
}
return normalizedQuotas;
}