mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-04 20:17:24 +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;
|
isForbidden?: boolean;
|
||||||
/** Error message if fetch failed */
|
/** Error message if fetch failed */
|
||||||
error?: string;
|
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 */
|
/** Google Cloud Code API endpoints */
|
||||||
@@ -56,6 +60,15 @@ interface AntigravityAuthFile {
|
|||||||
expires_in?: number;
|
expires_in?: number;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
project_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auth data returned from file */
|
||||||
|
interface AuthData {
|
||||||
|
accessToken: string;
|
||||||
|
projectId: string | null;
|
||||||
|
isExpired: boolean;
|
||||||
|
expiresAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** loadCodeAssist response */
|
/** 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();
|
const authDir = getAuthDir();
|
||||||
|
|
||||||
// Check if auth directory exists
|
// Check if auth directory exists
|
||||||
@@ -99,24 +133,48 @@ function readAccessToken(provider: CLIProxyProvider, accountId: string): string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Account ID format: email with @ and . replaced by _
|
// Sanitize accountId (email) to match auth file naming: @ and . → _
|
||||||
// Try to find matching token file
|
const sanitizedId = sanitizeEmail(accountId);
|
||||||
const files = fs.readdirSync(authDir);
|
|
||||||
const prefix = provider === 'agy' ? 'antigravity-' : `${provider}-`;
|
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) {
|
for (const file of files) {
|
||||||
if (file.startsWith(prefix) && file.endsWith('.json')) {
|
if (file.startsWith(prefix) && file.endsWith('.json')) {
|
||||||
// Check if this file matches the account ID
|
const candidatePath = path.join(authDir, file);
|
||||||
const baseName = file.replace(prefix, '').replace('.json', '');
|
try {
|
||||||
if (baseName === accountId || file === accountId || file === `${accountId}.json`) {
|
const content = fs.readFileSync(candidatePath, 'utf-8');
|
||||||
const filePath = path.join(authDir, file);
|
const data = JSON.parse(content) as AntigravityAuthFile;
|
||||||
try {
|
// Match by email field inside the auth file
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
if (data.email === accountId && data.access_token) {
|
||||||
const data = JSON.parse(content) as AntigravityAuthFile;
|
return {
|
||||||
return data.access_token || null;
|
accessToken: data.access_token,
|
||||||
} catch {
|
projectId: data.project_id || null,
|
||||||
return 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
|
* 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 url = `${ANTIGRAVITY_API_BASE}/${ANTIGRAVITY_API_VERSION}:loadCodeAssist`;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -153,7 +213,14 @@ async function getProjectId(accessToken: string): Promise<string | null> {
|
|||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
if (!response.ok) {
|
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;
|
const data = (await response.json()) as LoadCodeAssistResponse;
|
||||||
@@ -166,10 +233,17 @@ async function getProjectId(accessToken: string): Promise<string | null> {
|
|||||||
projectId = data.cloudaicompanionProject?.id;
|
projectId = data.cloudaicompanionProject?.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return projectId?.trim() || null;
|
if (!projectId?.trim()) {
|
||||||
} catch {
|
return { projectId: null, error: 'No project ID in response' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { projectId: projectId.trim() };
|
||||||
|
} catch (err) {
|
||||||
clearTimeout(timeoutId);
|
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
|
* Fetch quota for an Antigravity account
|
||||||
*
|
*
|
||||||
* @param provider - Provider name (only 'agy' supported)
|
* @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
|
* @returns Quota result with models and percentages
|
||||||
*/
|
*/
|
||||||
export async function fetchAccountQuota(
|
export async function fetchAccountQuota(
|
||||||
@@ -292,28 +366,44 @@ export async function fetchAccountQuota(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read access token from auth file
|
// Read auth data from auth file
|
||||||
const accessToken = readAccessToken(provider, accountId);
|
const authData = readAuthData(provider, accountId);
|
||||||
if (!accessToken) {
|
if (!authData) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
models: [],
|
models: [],
|
||||||
lastUpdated: Date.now(),
|
lastUpdated: Date.now(),
|
||||||
error: 'Access token not found for account',
|
error: 'Auth file not found for account',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get project ID first
|
// Check if token is expired
|
||||||
const projectId = await getProjectId(accessToken);
|
if (authData.isExpired) {
|
||||||
if (!projectId) {
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
models: [],
|
models: [],
|
||||||
lastUpdated: Date.now(),
|
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
|
// Fetch models with quota
|
||||||
return fetchAvailableModels(accessToken, projectId);
|
return fetchAvailableModels(authData.accessToken, projectId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
* Account Card Component for Flow Visualization
|
* 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 { 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 type { AccountData, DragOffset } from './types';
|
||||||
import { cleanEmail } from './utils';
|
import { cleanEmail } from './utils';
|
||||||
@@ -74,6 +76,18 @@ export function AccountCard({
|
|||||||
const borderColor = getBorderColorStyle(zone, account.color);
|
const borderColor = getBorderColorStyle(zone, account.color);
|
||||||
const connectorPosition = CONNECTOR_POSITION_MAP[zone];
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
data-account-index={originalIndex}
|
data-account-index={originalIndex}
|
||||||
@@ -114,6 +128,82 @@ export function AccountCard({
|
|||||||
failure={account.failureCount}
|
failure={account.failureCount}
|
||||||
showDetails={showDetails}
|
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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute w-3 h-3 rounded-full transform z-20 transition-colors border',
|
'absolute w-3 h-3 rounded-full transform z-20 transition-colors border',
|
||||||
|
|||||||
@@ -13,33 +13,21 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { User, Star, MoreHorizontal, Clock, Trash2, Loader2 } from 'lucide-react';
|
import {
|
||||||
import { cn } from '@/lib/utils';
|
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 { 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';
|
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
|
* Get color class based on quota percentage
|
||||||
*/
|
*/
|
||||||
@@ -49,6 +37,45 @@ function getQuotaColor(percentage: number): string {
|
|||||||
return 'bg-green-500';
|
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({
|
export function AccountItem({
|
||||||
account,
|
account,
|
||||||
onSetDefault,
|
onSetDefault,
|
||||||
@@ -57,6 +84,9 @@ export function AccountItem({
|
|||||||
privacyMode,
|
privacyMode,
|
||||||
showQuota,
|
showQuota,
|
||||||
}: AccountItemProps) {
|
}: 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
|
// Fetch quota for 'agy' provider accounts
|
||||||
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
||||||
account.provider,
|
account.provider,
|
||||||
@@ -64,6 +94,10 @@ export function AccountItem({
|
|||||||
showQuota && account.provider === 'agy'
|
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
|
// Calculate average quota across all models
|
||||||
const avgQuota =
|
const avgQuota =
|
||||||
quota?.success && quota.models.length > 0
|
quota?.success && quota.models.length > 0
|
||||||
@@ -72,16 +106,7 @@ export function AccountItem({
|
|||||||
|
|
||||||
// Get earliest reset time
|
// Get earliest reset time
|
||||||
const nextReset =
|
const nextReset =
|
||||||
quota?.success && quota.models.length > 0
|
quota?.success && quota.models.length > 0 ? getEarliestResetTime(quota.models) : null;
|
||||||
? 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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -155,36 +180,65 @@ export function AccountItem({
|
|||||||
<span>Loading quota...</span>
|
<span>Loading quota...</span>
|
||||||
</div>
|
</div>
|
||||||
) : avgQuota !== null ? (
|
) : avgQuota !== null ? (
|
||||||
<TooltipProvider>
|
<div className="space-y-1.5">
|
||||||
<Tooltip>
|
{/* Status indicator based on runtime usage, not file state */}
|
||||||
<TooltipTrigger asChild>
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
<div className="flex items-center gap-2">
|
{wasRecentlyUsed ? (
|
||||||
<Progress
|
<>
|
||||||
value={avgQuota}
|
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
|
||||||
className="h-2 flex-1"
|
<span className="text-emerald-600 dark:text-emerald-400">
|
||||||
indicatorClassName={getQuotaColor(avgQuota)}
|
Active · {formatRelativeTime(runtimeLastUsed)}
|
||||||
/>
|
</span>
|
||||||
<span className="text-xs font-medium w-10 text-right">{avgQuota}%</span>
|
</>
|
||||||
</div>
|
) : runtimeLastUsed ? (
|
||||||
</TooltipTrigger>
|
<>
|
||||||
<TooltipContent side="bottom" className="max-w-xs">
|
<Clock className="w-3 h-3 text-muted-foreground" />
|
||||||
<div className="text-xs space-y-1">
|
<span className="text-muted-foreground">
|
||||||
<p className="font-medium">Model Quotas:</p>
|
Last used {formatRelativeTime(runtimeLastUsed)}
|
||||||
{quota?.models.map((m) => (
|
</span>
|
||||||
<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>
|
<HelpCircle className="w-3 h-3 text-muted-foreground" />
|
||||||
))}
|
<span className="text-muted-foreground">Not used yet</span>
|
||||||
{nextReset && (
|
</>
|
||||||
<p className="text-muted-foreground mt-1">
|
)}
|
||||||
Resets {formatResetTime(nextReset)}
|
</div>
|
||||||
</p>
|
{/* Quota bar */}
|
||||||
)}
|
<TooltipProvider>
|
||||||
</div>
|
<Tooltip>
|
||||||
</TooltipContent>
|
<TooltipTrigger asChild>
|
||||||
</Tooltip>
|
<div className="flex items-center gap-2">
|
||||||
</TooltipProvider>
|
<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 ? (
|
) : quota?.error ? (
|
||||||
<div className="text-xs text-muted-foreground">{quota.error}</div>
|
<div className="text-xs text-muted-foreground">{quota.error}</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* OAuth Auth Flow Hook for CLIProxy
|
* 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';
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
@@ -13,19 +13,7 @@ interface AuthFlowState {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUTH_ENDPOINTS: Record<string, string> = {
|
const VALID_PROVIDERS = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp'];
|
||||||
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;
|
|
||||||
|
|
||||||
export function useCliproxyAuthFlow() {
|
export function useCliproxyAuthFlow() {
|
||||||
const [state, setState] = useState<AuthFlowState>({
|
const [state, setState] = useState<AuthFlowState>({
|
||||||
@@ -34,36 +22,19 @@ export function useCliproxyAuthFlow() {
|
|||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const popupRef = useRef<Window | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const queryClient = useQueryClient();
|
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
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => cleanup();
|
return () => {
|
||||||
}, [cleanup]);
|
abortControllerRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const startAuth = useCallback(
|
const startAuth = useCallback(
|
||||||
async (provider: string) => {
|
async (provider: string) => {
|
||||||
const endpoint = AUTH_ENDPOINTS[provider];
|
if (!VALID_PROVIDERS.includes(provider)) {
|
||||||
if (!endpoint) {
|
|
||||||
setState({
|
setState({
|
||||||
provider: null,
|
provider: null,
|
||||||
isAuthenticating: false,
|
isAuthenticating: false,
|
||||||
@@ -72,117 +43,48 @@ export function useCliproxyAuthFlow() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Abort any in-progress auth
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
setState({ provider, isAuthenticating: true, error: null });
|
setState({ provider, isAuthenticating: true, error: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get auth URL from API
|
// POST to CCS auth endpoint - backend opens browser and waits
|
||||||
const response = await fetch(`/api/cliproxy${endpoint}?is_webui=true`);
|
const response = await fetch(`/api/cliproxy/auth/${provider}/start`, {
|
||||||
if (!response.ok) {
|
method: 'POST',
|
||||||
throw new Error(`Failed to get auth URL: ${response.statusText}`);
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
body: JSON.stringify({}),
|
||||||
|
signal: abortControllerRef.current.signal,
|
||||||
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const { url, state: authState } = data;
|
|
||||||
|
|
||||||
if (!url) {
|
if (response.ok && data.success) {
|
||||||
throw new Error('No auth URL returned from server');
|
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) {
|
} 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';
|
const message = error instanceof Error ? error.message : 'Authentication failed';
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
setState({ provider: null, isAuthenticating: false, error: message });
|
setState({ provider: null, isAuthenticating: false, error: message });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[cleanup, queryClient]
|
[queryClient]
|
||||||
);
|
);
|
||||||
|
|
||||||
const cancelAuth = useCallback(() => {
|
const cancelAuth = useCallback(() => {
|
||||||
cleanup();
|
abortControllerRef.current?.abort();
|
||||||
setState({ provider: null, isAuthenticating: false, error: null });
|
setState({ provider: null, isAuthenticating: false, error: null });
|
||||||
}, [cleanup]);
|
}, []);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -221,8 +221,8 @@ export function useAccountQuota(provider: string, accountId: string, enabled = t
|
|||||||
queryKey: ['account-quota', provider, accountId],
|
queryKey: ['account-quota', provider, accountId],
|
||||||
queryFn: () => fetchAccountQuota(provider, accountId),
|
queryFn: () => fetchAccountQuota(provider, accountId),
|
||||||
enabled: enabled && provider === 'agy' && !!accountId,
|
enabled: enabled && provider === 'agy' && !!accountId,
|
||||||
staleTime: 60000, // Cache for 1 minute
|
staleTime: 30000, // Consider stale after 30s (tokens can refresh anytime)
|
||||||
refetchInterval: 300000, // Refresh every 5 minutes
|
refetchInterval: 60000, // Refresh every 1 minute
|
||||||
retry: 1,
|
retry: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,3 +54,65 @@ export function getProviderColor(provider: string): string {
|
|||||||
const normalized = provider.toLowerCase();
|
const normalized = provider.toLowerCase();
|
||||||
return PROVIDER_COLORS[normalized] || getModelColor(provider);
|
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