mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
feat(ui): replace misleading token expiry with runtime-based status
- Remove "Token expired" warning (showed stale file state, not runtime) - Add "Active/Last used" status based on CLIProxyAPI runtime stats - Show green checkmark for recently used accounts (within 1h) - Show "Not used yet" for accounts without usage stats - Remove expired warning from flow-viz account cards - Add model quota sorting (Claude > Gemini > GPT > other) - Add quota reset time display in tooltips - Fix re-auth button to use correct CCS endpoint - Reduce quota cache staleness (30s stale, 1m refresh) CLIProxyAPI intentionally doesn't persist refreshed tokens to disk (to prevent refresh loops), so file-based expiry was misleading. Dashboard now shows truthful operational state from runtime stats.
This commit is contained in:
+120
-30
@@ -34,6 +34,10 @@ export interface QuotaResult {
|
||||
isForbidden?: boolean;
|
||||
/** Error message if fetch failed */
|
||||
error?: string;
|
||||
/** True if token is expired and needs re-auth */
|
||||
isExpired?: boolean;
|
||||
/** ISO timestamp when token expires/expired */
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
/** Google Cloud Code API endpoints */
|
||||
@@ -56,6 +60,15 @@ interface AntigravityAuthFile {
|
||||
expires_in?: number;
|
||||
timestamp?: number;
|
||||
type?: string;
|
||||
project_id?: string;
|
||||
}
|
||||
|
||||
/** Auth data returned from file */
|
||||
interface AuthData {
|
||||
accessToken: string;
|
||||
projectId: string | null;
|
||||
isExpired: boolean;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
/** loadCodeAssist response */
|
||||
@@ -89,9 +102,30 @@ interface FetchAvailableModelsResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read access token from auth file
|
||||
* Sanitize email to match CLIProxyAPI auth file naming convention
|
||||
* Replaces @ and . with underscores (matches Go sanitizeAntigravityFileName)
|
||||
*/
|
||||
function readAccessToken(provider: CLIProxyProvider, accountId: string): string | null {
|
||||
function sanitizeEmail(email: string): string {
|
||||
return email.replace(/@/g, '_').replace(/\./g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired based on the expired timestamp
|
||||
*/
|
||||
function isTokenExpired(expiredStr?: string): boolean {
|
||||
if (!expiredStr) return false;
|
||||
try {
|
||||
const expiredDate = new Date(expiredStr);
|
||||
return expiredDate.getTime() < Date.now();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read auth data from auth file (access token, project_id, expiry status)
|
||||
*/
|
||||
function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData | null {
|
||||
const authDir = getAuthDir();
|
||||
|
||||
// Check if auth directory exists
|
||||
@@ -99,24 +133,48 @@ function readAccessToken(provider: CLIProxyProvider, accountId: string): string
|
||||
return null;
|
||||
}
|
||||
|
||||
// Account ID format: email with @ and . replaced by _
|
||||
// Try to find matching token file
|
||||
const files = fs.readdirSync(authDir);
|
||||
// Sanitize accountId (email) to match auth file naming: @ and . → _
|
||||
const sanitizedId = sanitizeEmail(accountId);
|
||||
const prefix = provider === 'agy' ? 'antigravity-' : `${provider}-`;
|
||||
const expectedFile = `${prefix}${sanitizedId}.json`;
|
||||
const filePath = path.join(authDir, expectedFile);
|
||||
|
||||
// Direct file access (most common case)
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content) as AntigravityAuthFile;
|
||||
if (!data.access_token) return null;
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
projectId: data.project_id || null,
|
||||
isExpired: isTokenExpired(data.expired),
|
||||
expiresAt: data.expired || null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: scan directory for matching email in file content
|
||||
const files = fs.readdirSync(authDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith(prefix) && file.endsWith('.json')) {
|
||||
// Check if this file matches the account ID
|
||||
const baseName = file.replace(prefix, '').replace('.json', '');
|
||||
if (baseName === accountId || file === accountId || file === `${accountId}.json`) {
|
||||
const filePath = path.join(authDir, file);
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content) as AntigravityAuthFile;
|
||||
return data.access_token || null;
|
||||
} catch {
|
||||
return null;
|
||||
const candidatePath = path.join(authDir, file);
|
||||
try {
|
||||
const content = fs.readFileSync(candidatePath, 'utf-8');
|
||||
const data = JSON.parse(content) as AntigravityAuthFile;
|
||||
// Match by email field inside the auth file
|
||||
if (data.email === accountId && data.access_token) {
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
projectId: data.project_id || null,
|
||||
isExpired: isTokenExpired(data.expired),
|
||||
expiresAt: data.expired || null,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,7 +185,9 @@ function readAccessToken(provider: CLIProxyProvider, accountId: string): string
|
||||
/**
|
||||
* Get project ID via loadCodeAssist endpoint
|
||||
*/
|
||||
async function getProjectId(accessToken: string): Promise<string | null> {
|
||||
async function getProjectId(
|
||||
accessToken: string
|
||||
): Promise<{ projectId: string | null; error?: string }> {
|
||||
const url = `${ANTIGRAVITY_API_BASE}/${ANTIGRAVITY_API_VERSION}:loadCodeAssist`;
|
||||
|
||||
const controller = new AbortController();
|
||||
@@ -153,7 +213,14 @@ async function getProjectId(accessToken: string): Promise<string | null> {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
// Return specific error based on status
|
||||
if (response.status === 401) {
|
||||
return { projectId: null, error: 'Token expired or invalid' };
|
||||
}
|
||||
if (response.status === 403) {
|
||||
return { projectId: null, error: 'Access forbidden' };
|
||||
}
|
||||
return { projectId: null, error: `API error: ${response.status}` };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as LoadCodeAssistResponse;
|
||||
@@ -166,10 +233,17 @@ async function getProjectId(accessToken: string): Promise<string | null> {
|
||||
projectId = data.cloudaicompanionProject?.id;
|
||||
}
|
||||
|
||||
return projectId?.trim() || null;
|
||||
} catch {
|
||||
if (!projectId?.trim()) {
|
||||
return { projectId: null, error: 'No project ID in response' };
|
||||
}
|
||||
|
||||
return { projectId: projectId.trim() };
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
return null;
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return { projectId: null, error: 'Request timeout' };
|
||||
}
|
||||
return { projectId: null, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +349,7 @@ async function fetchAvailableModels(accessToken: string, projectId: string): Pro
|
||||
* Fetch quota for an Antigravity account
|
||||
*
|
||||
* @param provider - Provider name (only 'agy' supported)
|
||||
* @param accountId - Account identifier (email with _ replacing @ and .)
|
||||
* @param accountId - Account identifier (email)
|
||||
* @returns Quota result with models and percentages
|
||||
*/
|
||||
export async function fetchAccountQuota(
|
||||
@@ -292,28 +366,44 @@ export async function fetchAccountQuota(
|
||||
};
|
||||
}
|
||||
|
||||
// Read access token from auth file
|
||||
const accessToken = readAccessToken(provider, accountId);
|
||||
if (!accessToken) {
|
||||
// Read auth data from auth file
|
||||
const authData = readAuthData(provider, accountId);
|
||||
if (!authData) {
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error: 'Access token not found for account',
|
||||
error: 'Auth file not found for account',
|
||||
};
|
||||
}
|
||||
|
||||
// Get project ID first
|
||||
const projectId = await getProjectId(accessToken);
|
||||
if (!projectId) {
|
||||
// Check if token is expired
|
||||
if (authData.isExpired) {
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error: 'Failed to retrieve project ID',
|
||||
isExpired: true,
|
||||
expiresAt: authData.expiresAt || undefined,
|
||||
error: 'Token expired',
|
||||
};
|
||||
}
|
||||
|
||||
// Get project ID - prefer stored value, fallback to API call
|
||||
let projectId = authData.projectId;
|
||||
if (!projectId) {
|
||||
const projectResult = await getProjectId(authData.accessToken);
|
||||
if (!projectResult.projectId) {
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error: projectResult.error || 'Failed to retrieve project ID',
|
||||
};
|
||||
}
|
||||
projectId = projectResult.projectId;
|
||||
}
|
||||
|
||||
// Fetch models with quota
|
||||
return fetchAvailableModels(accessToken, projectId);
|
||||
return fetchAvailableModels(authData.accessToken, projectId);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
* Account Card Component for Flow Visualization
|
||||
*/
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, sortModelsByPriority, formatResetTime, getEarliestResetTime } from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import { GripVertical, Loader2, Clock } from 'lucide-react';
|
||||
import { useAccountQuota } from '@/hooks/use-cliproxy-stats';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
import type { AccountData, DragOffset } from './types';
|
||||
import { cleanEmail } from './utils';
|
||||
@@ -74,6 +76,18 @@ export function AccountCard({
|
||||
const borderColor = getBorderColorStyle(zone, account.color);
|
||||
const connectorPosition = CONNECTOR_POSITION_MAP[zone];
|
||||
|
||||
// Quota for AGY accounts
|
||||
const isAgy = account.provider === 'agy';
|
||||
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
||||
account.provider,
|
||||
account.id,
|
||||
isAgy
|
||||
);
|
||||
const avgQuota =
|
||||
quota?.success && quota.models.length > 0
|
||||
? Math.round(quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-account-index={originalIndex}
|
||||
@@ -114,6 +128,82 @@ export function AccountCard({
|
||||
failure={account.failureCount}
|
||||
showDetails={showDetails}
|
||||
/>
|
||||
{/* Quota bar for AGY accounts */}
|
||||
{isAgy && (
|
||||
<div className="mt-2 px-0.5">
|
||||
{quotaLoading ? (
|
||||
<div className="flex items-center gap-1 text-[8px] text-muted-foreground">
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||
<span>Quota...</span>
|
||||
</div>
|
||||
) : avgQuota !== null ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="space-y-0.5 cursor-help">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
|
||||
Quota
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-mono font-bold',
|
||||
avgQuota > 50
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: avgQuota > 20
|
||||
? 'text-amber-500'
|
||||
: 'text-red-500'
|
||||
)}
|
||||
>
|
||||
{avgQuota}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
avgQuota > 50
|
||||
? 'bg-emerald-500'
|
||||
: avgQuota > 20
|
||||
? 'bg-amber-500'
|
||||
: 'bg-red-500'
|
||||
)}
|
||||
style={{ width: `${avgQuota}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium">Model Quotas:</p>
|
||||
{sortModelsByPriority(quota?.models || []).map((m) => (
|
||||
<div key={m.name} className="flex justify-between gap-4">
|
||||
<span className="truncate">{m.displayName || m.name}</span>
|
||||
<span className="font-mono">{m.percentage}%</span>
|
||||
</div>
|
||||
))}
|
||||
{(() => {
|
||||
const resetTime = getEarliestResetTime(quota?.models || []);
|
||||
return resetTime ? (
|
||||
<div className="flex items-center gap-1.5 mt-2 pt-2 border-t border-border/50">
|
||||
<Clock className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-blue-400 font-medium">
|
||||
Resets {formatResetTime(resetTime)}
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : quota?.error ? (
|
||||
<div className="text-[8px] text-muted-foreground/60 truncate" title={quota.error}>
|
||||
{quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute w-3 h-3 rounded-full transform z-20 transition-colors border',
|
||||
|
||||
@@ -13,33 +13,21 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { User, Star, MoreHorizontal, Clock, Trash2, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
User,
|
||||
Star,
|
||||
MoreHorizontal,
|
||||
Clock,
|
||||
Trash2,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
HelpCircle,
|
||||
} from 'lucide-react';
|
||||
import { cn, sortModelsByPriority, formatResetTime, getEarliestResetTime } from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { useAccountQuota } from '@/hooks/use-cliproxy-stats';
|
||||
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||
import type { AccountItemProps } from './types';
|
||||
|
||||
/**
|
||||
* Format reset time as relative time (e.g., "in 2 hours")
|
||||
*/
|
||||
function formatResetTime(resetTime: string | null): string | null {
|
||||
if (!resetTime) return null;
|
||||
try {
|
||||
const reset = new Date(resetTime);
|
||||
const now = new Date();
|
||||
const diff = reset.getTime() - now.getTime();
|
||||
if (diff <= 0) return 'soon';
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 0) return `in ${hours}h ${minutes}m`;
|
||||
return `in ${minutes}m`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color class based on quota percentage
|
||||
*/
|
||||
@@ -49,6 +37,45 @@ function getQuotaColor(percentage: number): string {
|
||||
return 'bg-green-500';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "5m ago", "2h ago")
|
||||
*/
|
||||
function formatRelativeTime(dateStr: string | undefined): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
if (diff < 0) return 'just now';
|
||||
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days > 0) return `${days}d ago`;
|
||||
if (hours > 0) return `${hours}h ago`;
|
||||
if (minutes > 0) return `${minutes}m ago`;
|
||||
return 'just now';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if account was used recently (within last hour = token likely refreshed)
|
||||
*/
|
||||
function isRecentlyUsed(lastUsedAt: string | undefined): boolean {
|
||||
if (!lastUsedAt) return false;
|
||||
try {
|
||||
const lastUsed = new Date(lastUsedAt);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - lastUsed.getTime();
|
||||
return diff < 60 * 60 * 1000; // Within last hour
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function AccountItem({
|
||||
account,
|
||||
onSetDefault,
|
||||
@@ -57,6 +84,9 @@ export function AccountItem({
|
||||
privacyMode,
|
||||
showQuota,
|
||||
}: AccountItemProps) {
|
||||
// Fetch runtime stats to get actual lastUsedAt (more accurate than file state)
|
||||
const { data: stats } = useCliproxyStats(showQuota && account.provider === 'agy');
|
||||
|
||||
// Fetch quota for 'agy' provider accounts
|
||||
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
||||
account.provider,
|
||||
@@ -64,6 +94,10 @@ export function AccountItem({
|
||||
showQuota && account.provider === 'agy'
|
||||
);
|
||||
|
||||
// Get last used time from runtime stats (more accurate than file)
|
||||
const runtimeLastUsed = stats?.accountStats?.[account.email || account.id]?.lastUsedAt;
|
||||
const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed);
|
||||
|
||||
// Calculate average quota across all models
|
||||
const avgQuota =
|
||||
quota?.success && quota.models.length > 0
|
||||
@@ -72,16 +106,7 @@ export function AccountItem({
|
||||
|
||||
// Get earliest reset time
|
||||
const nextReset =
|
||||
quota?.success && quota.models.length > 0
|
||||
? quota.models.reduce(
|
||||
(earliest, m) => {
|
||||
if (!m.resetTime) return earliest;
|
||||
if (!earliest) return m.resetTime;
|
||||
return new Date(m.resetTime) < new Date(earliest) ? m.resetTime : earliest;
|
||||
},
|
||||
null as string | null
|
||||
)
|
||||
: null;
|
||||
quota?.success && quota.models.length > 0 ? getEarliestResetTime(quota.models) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -155,36 +180,65 @@ export function AccountItem({
|
||||
<span>Loading quota...</span>
|
||||
</div>
|
||||
) : avgQuota !== null ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={avgQuota}
|
||||
className="h-2 flex-1"
|
||||
indicatorClassName={getQuotaColor(avgQuota)}
|
||||
/>
|
||||
<span className="text-xs font-medium w-10 text-right">{avgQuota}%</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium">Model Quotas:</p>
|
||||
{quota?.models.map((m) => (
|
||||
<div key={m.name} className="flex justify-between gap-4">
|
||||
<span className="truncate">{m.displayName || m.name}</span>
|
||||
<span className="font-mono">{m.percentage}%</span>
|
||||
</div>
|
||||
))}
|
||||
{nextReset && (
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Resets {formatResetTime(nextReset)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="space-y-1.5">
|
||||
{/* Status indicator based on runtime usage, not file state */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{wasRecentlyUsed ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
|
||||
<span className="text-emerald-600 dark:text-emerald-400">
|
||||
Active · {formatRelativeTime(runtimeLastUsed)}
|
||||
</span>
|
||||
</>
|
||||
) : runtimeLastUsed ? (
|
||||
<>
|
||||
<Clock className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
Last used {formatRelativeTime(runtimeLastUsed)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<HelpCircle className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Not used yet</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Quota bar */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={avgQuota}
|
||||
className="h-2 flex-1"
|
||||
indicatorClassName={getQuotaColor(avgQuota)}
|
||||
/>
|
||||
<span className="text-xs font-medium w-10 text-right">{avgQuota}%</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium">Model Quotas:</p>
|
||||
{sortModelsByPriority(quota?.models || []).map((m) => (
|
||||
<div key={m.name} className="flex justify-between gap-4">
|
||||
<span className="truncate">{m.displayName || m.name}</span>
|
||||
<span className="font-mono">{m.percentage}%</span>
|
||||
</div>
|
||||
))}
|
||||
{nextReset && (
|
||||
<div className="flex items-center gap-1.5 mt-2 pt-2 border-t border-border/50">
|
||||
<Clock className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-blue-400 font-medium">
|
||||
Resets {formatResetTime(nextReset)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
) : quota?.error ? (
|
||||
<div className="text-xs text-muted-foreground">{quota.error}</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* OAuth Auth Flow Hook for CLIProxy
|
||||
* Manages popup-based OAuth authentication flows
|
||||
* Triggers backend-managed OAuth authentication flows
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
@@ -13,19 +13,7 @@ interface AuthFlowState {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const AUTH_ENDPOINTS: Record<string, string> = {
|
||||
claude: '/anthropic-auth-url',
|
||||
gemini: '/gemini-cli-auth-url',
|
||||
codex: '/codex-auth-url',
|
||||
agy: '/antigravity-auth-url',
|
||||
qwen: '/qwen-auth-url',
|
||||
iflow: '/iflow-auth-url',
|
||||
kiro: '/kiro-auth-url',
|
||||
ghcp: '/ghcp-auth-url',
|
||||
};
|
||||
|
||||
const AUTH_TIMEOUT_MS = 300000; // 5 minutes
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
const VALID_PROVIDERS = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp'];
|
||||
|
||||
export function useCliproxyAuthFlow() {
|
||||
const [state, setState] = useState<AuthFlowState>({
|
||||
@@ -34,36 +22,19 @@ export function useCliproxyAuthFlow() {
|
||||
error: null,
|
||||
});
|
||||
|
||||
const popupRef = useRef<Window | null>(null);
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Cleanup function
|
||||
const cleanup = useCallback(() => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
if (popupRef.current && !popupRef.current.closed) {
|
||||
popupRef.current.close();
|
||||
}
|
||||
popupRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => cleanup();
|
||||
}, [cleanup]);
|
||||
return () => {
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startAuth = useCallback(
|
||||
async (provider: string) => {
|
||||
const endpoint = AUTH_ENDPOINTS[provider];
|
||||
if (!endpoint) {
|
||||
if (!VALID_PROVIDERS.includes(provider)) {
|
||||
setState({
|
||||
provider: null,
|
||||
isAuthenticating: false,
|
||||
@@ -72,117 +43,48 @@ export function useCliproxyAuthFlow() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort any in-progress auth
|
||||
abortControllerRef.current?.abort();
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
setState({ provider, isAuthenticating: true, error: null });
|
||||
|
||||
try {
|
||||
// Get auth URL from API
|
||||
const response = await fetch(`/api/cliproxy${endpoint}?is_webui=true`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get auth URL: ${response.statusText}`);
|
||||
}
|
||||
// POST to CCS auth endpoint - backend opens browser and waits
|
||||
const response = await fetch(`/api/cliproxy/auth/${provider}/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const { url, state: authState } = data;
|
||||
|
||||
if (!url) {
|
||||
throw new Error('No auth URL returned from server');
|
||||
if (response.ok && data.success) {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['account-quota'] });
|
||||
toast.success(`${provider} authentication successful`);
|
||||
setState({ provider: null, isAuthenticating: false, error: null });
|
||||
} else {
|
||||
throw new Error(data.error || 'Authentication failed');
|
||||
}
|
||||
|
||||
// Open popup
|
||||
const popup = window.open(url, `${provider}_auth`, 'width=600,height=700,popup=yes');
|
||||
|
||||
if (!popup) {
|
||||
throw new Error('Popup blocked. Please allow popups for this site.');
|
||||
}
|
||||
|
||||
popupRef.current = popup;
|
||||
|
||||
// Poll for completion
|
||||
pollIntervalRef.current = setInterval(async () => {
|
||||
// Check if popup was closed by user
|
||||
if (popup.closed) {
|
||||
cleanup();
|
||||
// Check final status
|
||||
try {
|
||||
const statusRes = await fetch(`/api/cliproxy/get-auth-status?state=${authState}`);
|
||||
const statusData = await statusRes.json();
|
||||
|
||||
if (statusData.status === 'ok') {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
toast.success(`${provider} authentication successful`);
|
||||
setState({ provider: null, isAuthenticating: false, error: null });
|
||||
} else if (statusData.status === 'error') {
|
||||
setState({
|
||||
provider: null,
|
||||
isAuthenticating: false,
|
||||
error: statusData.error || 'Authentication failed',
|
||||
});
|
||||
} else {
|
||||
// User closed popup before completing
|
||||
setState({
|
||||
provider: null,
|
||||
isAuthenticating: false,
|
||||
error: 'Authentication cancelled',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setState({
|
||||
provider: null,
|
||||
isAuthenticating: false,
|
||||
error: 'Failed to check auth status',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Poll status while popup is open
|
||||
try {
|
||||
const statusRes = await fetch(`/api/cliproxy/get-auth-status?state=${authState}`);
|
||||
const statusData = await statusRes.json();
|
||||
|
||||
if (statusData.status === 'ok') {
|
||||
cleanup();
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
toast.success(`${provider} authentication successful`);
|
||||
setState({ provider: null, isAuthenticating: false, error: null });
|
||||
} else if (statusData.status === 'error') {
|
||||
cleanup();
|
||||
setState({
|
||||
provider: null,
|
||||
isAuthenticating: false,
|
||||
error: statusData.error || 'Authentication failed',
|
||||
});
|
||||
}
|
||||
// 'wait' status means keep polling
|
||||
} catch {
|
||||
// Silently ignore polling errors, will retry
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
// Timeout after 5 minutes
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
cleanup();
|
||||
toast.error('Authentication timed out');
|
||||
setState({
|
||||
provider: null,
|
||||
isAuthenticating: false,
|
||||
error: 'Authentication timed out',
|
||||
});
|
||||
}, AUTH_TIMEOUT_MS);
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
setState({ provider: null, isAuthenticating: false, error: null });
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Authentication failed';
|
||||
toast.error(message);
|
||||
setState({ provider: null, isAuthenticating: false, error: message });
|
||||
}
|
||||
},
|
||||
[cleanup, queryClient]
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
const cancelAuth = useCallback(() => {
|
||||
cleanup();
|
||||
abortControllerRef.current?.abort();
|
||||
setState({ provider: null, isAuthenticating: false, error: null });
|
||||
}, [cleanup]);
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -221,8 +221,8 @@ export function useAccountQuota(provider: string, accountId: string, enabled = t
|
||||
queryKey: ['account-quota', provider, accountId],
|
||||
queryFn: () => fetchAccountQuota(provider, accountId),
|
||||
enabled: enabled && provider === 'agy' && !!accountId,
|
||||
staleTime: 60000, // Cache for 1 minute
|
||||
refetchInterval: 300000, // Refresh every 5 minutes
|
||||
staleTime: 30000, // Consider stale after 30s (tokens can refresh anytime)
|
||||
refetchInterval: 60000, // Refresh every 1 minute
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -54,3 +54,65 @@ export function getProviderColor(provider: string): string {
|
||||
const normalized = provider.toLowerCase();
|
||||
return PROVIDER_COLORS[normalized] || getModelColor(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort models with Claude models first, then alphabetically
|
||||
* Prioritizes: Claude > Gemini > GPT > Other (alphabetically)
|
||||
*/
|
||||
export function sortModelsByPriority<T extends { name: string; displayName?: string }>(
|
||||
models: T[]
|
||||
): T[] {
|
||||
const getPriority = (model: T): number => {
|
||||
const name = (model.displayName || model.name).toLowerCase();
|
||||
if (name.includes('claude')) return 0;
|
||||
if (name.includes('gemini')) return 1;
|
||||
if (name.includes('gpt')) return 2;
|
||||
return 3;
|
||||
};
|
||||
|
||||
return [...models].sort((a, b) => {
|
||||
const priorityDiff = getPriority(a) - getPriority(b);
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
// Same priority: sort alphabetically by display name
|
||||
const nameA = (a.displayName || a.name).toLowerCase();
|
||||
const nameB = (b.displayName || b.name).toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reset time as relative time (e.g., "in 2h 30m")
|
||||
*/
|
||||
export function formatResetTime(resetTime: string | null): string | null {
|
||||
if (!resetTime) return null;
|
||||
try {
|
||||
const reset = new Date(resetTime);
|
||||
const now = new Date();
|
||||
const diff = reset.getTime() - now.getTime();
|
||||
if (diff <= 0) return 'soon';
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 0) return `in ${hours}h ${minutes}m`;
|
||||
return `in ${minutes}m`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get earliest reset time from models array
|
||||
*/
|
||||
export function getEarliestResetTime<T extends { resetTime: string | null }>(
|
||||
models: T[]
|
||||
): string | null {
|
||||
return models.reduce(
|
||||
(earliest, m) => {
|
||||
if (!m.resetTime) return earliest;
|
||||
if (!earliest) return m.resetTime;
|
||||
return new Date(m.resetTime) < new Date(earliest) ? m.resetTime : earliest;
|
||||
},
|
||||
null as string | null
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user