mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 12:14:19 +00:00
Merge pull request #369 from kaitranntt/kai/fix/account-control-ux-fixes
fix(ui): account control UX improvements
This commit is contained in:
20 files changed
+1077
-144
No files matched your search
@@ -835,6 +835,113 @@ export function discoverExistingAccounts(): void {
|
||||
saveAccountsRegistry(freshRegistry);
|
||||
}
|
||||
|
||||
/** Result of bulk pause/resume operations */
|
||||
export interface BulkOperationResult {
|
||||
succeeded: string[];
|
||||
failed: Array<{ id: string; reason: string }>;
|
||||
}
|
||||
|
||||
/** Result of solo account operation */
|
||||
export interface SoloOperationResult {
|
||||
activated: string;
|
||||
paused: string[];
|
||||
}
|
||||
|
||||
/** Provider-level locks for preventing race conditions in solo mode */
|
||||
const providerLocks = new Map<CLIProxyProvider, Promise<unknown>>();
|
||||
|
||||
/**
|
||||
* Bulk pause multiple accounts
|
||||
* Pauses each account, collecting successes and failures (no fail-fast)
|
||||
*/
|
||||
export function bulkPauseAccounts(
|
||||
provider: CLIProxyProvider,
|
||||
accountIds: string[]
|
||||
): BulkOperationResult {
|
||||
const result: BulkOperationResult = { succeeded: [], failed: [] };
|
||||
|
||||
for (const id of accountIds) {
|
||||
const success = pauseAccount(provider, id);
|
||||
if (success) {
|
||||
result.succeeded.push(id);
|
||||
} else {
|
||||
result.failed.push({ id, reason: 'Account not found' });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk resume multiple accounts
|
||||
* Resumes each account, collecting successes and failures (no fail-fast)
|
||||
*/
|
||||
export function bulkResumeAccounts(
|
||||
provider: CLIProxyProvider,
|
||||
accountIds: string[]
|
||||
): BulkOperationResult {
|
||||
const result: BulkOperationResult = { succeeded: [], failed: [] };
|
||||
|
||||
for (const id of accountIds) {
|
||||
const success = resumeAccount(provider, id);
|
||||
if (success) {
|
||||
result.succeeded.push(id);
|
||||
} else {
|
||||
result.failed.push({ id, reason: 'Account not found' });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Solo mode: activate one account, pause all others in same provider
|
||||
* Per validation: auto-resumes target if paused, keeps default unchanged
|
||||
*/
|
||||
export async function soloAccount(
|
||||
provider: CLIProxyProvider,
|
||||
accountId: string
|
||||
): Promise<SoloOperationResult | null> {
|
||||
// Wait for any pending operation on this provider
|
||||
const pending = providerLocks.get(provider);
|
||||
if (pending) await pending;
|
||||
|
||||
const operation = (async () => {
|
||||
const accounts = getProviderAccounts(provider);
|
||||
const targetAccount = accounts.find((a) => a.id === accountId);
|
||||
|
||||
if (!targetAccount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: SoloOperationResult = { activated: accountId, paused: [] };
|
||||
|
||||
// Resume target account if paused (per validation decision)
|
||||
if (targetAccount.paused) {
|
||||
resumeAccount(provider, accountId);
|
||||
}
|
||||
|
||||
// Pause all other accounts
|
||||
for (const account of accounts) {
|
||||
if (account.id !== accountId && !account.paused) {
|
||||
const success = pauseAccount(provider, account.id);
|
||||
if (success) {
|
||||
result.paused.push(account.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
})();
|
||||
|
||||
providerLocks.set(provider, operation);
|
||||
try {
|
||||
return await operation;
|
||||
} finally {
|
||||
providerLocks.delete(provider);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary of all accounts across providers
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getAuthDir } from './config-generator';
|
||||
import { CLIProxyProvider } from './types';
|
||||
import {
|
||||
getProviderAccounts,
|
||||
isAccountPaused,
|
||||
getPausedDir,
|
||||
setAccountTier,
|
||||
type AccountInfo,
|
||||
type AccountTier,
|
||||
@@ -242,59 +242,60 @@ async function refreshAccessToken(
|
||||
* 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
|
||||
if (!fs.existsSync(authDir)) {
|
||||
return null;
|
||||
}
|
||||
// Check both active and paused auth directories (quota needed for paused accounts too)
|
||||
const authDirs = [getAuthDir(), getPausedDir()];
|
||||
|
||||
// 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,
|
||||
refreshToken: data.refresh_token || null,
|
||||
projectId: data.project_id || null,
|
||||
isExpired: isTokenExpired(data.expired),
|
||||
expiresAt: data.expired || null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
for (const authDir of authDirs) {
|
||||
if (!fs.existsSync(authDir)) continue;
|
||||
|
||||
// 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')) {
|
||||
const candidatePath = path.join(authDir, file);
|
||||
const filePath = path.join(authDir, expectedFile);
|
||||
|
||||
// Direct file access (most common case)
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(candidatePath, 'utf-8');
|
||||
const content = fs.readFileSync(filePath, '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,
|
||||
refreshToken: data.refresh_token || null,
|
||||
projectId: data.project_id || null,
|
||||
isExpired: isTokenExpired(data.expired),
|
||||
expiresAt: data.expired || null,
|
||||
};
|
||||
}
|
||||
if (!data.access_token) continue;
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token || null,
|
||||
projectId: data.project_id || null,
|
||||
isExpired: isTokenExpired(data.expired),
|
||||
expiresAt: data.expired || null,
|
||||
};
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 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')) {
|
||||
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,
|
||||
refreshToken: data.refresh_token || null,
|
||||
projectId: data.project_id || null,
|
||||
isExpired: isTokenExpired(data.expired),
|
||||
expiresAt: data.expired || null,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -493,15 +494,22 @@ async function fetchAvailableModels(accessToken: string, _projectId: string): Pr
|
||||
const remaining =
|
||||
quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining;
|
||||
|
||||
// Skip invalid values (NaN, Infinity, non-numbers)
|
||||
if (typeof remaining !== 'number' || !isFinite(remaining)) continue;
|
||||
|
||||
// Convert to percentage (0-100) and clamp to valid range
|
||||
const percentage = Math.max(0, Math.min(100, Math.round(remaining * 100)));
|
||||
|
||||
// Extract reset time
|
||||
const resetTime = quotaInfo.resetTime || quotaInfo.reset_time || null;
|
||||
|
||||
// If remaining is not a valid number but resetTime exists, treat as exhausted (0%)
|
||||
// This happens when Claude models hit quota limit - API returns resetTime but no fraction
|
||||
let percentage: number;
|
||||
if (typeof remaining === 'number' && isFinite(remaining)) {
|
||||
percentage = Math.max(0, Math.min(100, Math.round(remaining * 100)));
|
||||
} else if (resetTime) {
|
||||
// Model is exhausted but has reset time - show as 0%
|
||||
percentage = 0;
|
||||
} else {
|
||||
// No valid data, skip this model
|
||||
continue;
|
||||
}
|
||||
|
||||
models.push({
|
||||
name: modelId,
|
||||
displayName: modelData.displayName,
|
||||
@@ -554,19 +562,7 @@ export async function fetchAccountQuota(
|
||||
};
|
||||
}
|
||||
|
||||
// Check if account is paused (token moved to auth-paused/ directory)
|
||||
if (isAccountPaused(provider, accountId)) {
|
||||
const error = 'Account is paused';
|
||||
if (verbose) console.error(`[i] ${error}`);
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
// Read auth data from auth file
|
||||
// Read auth data from auth file (checks both active and paused directories)
|
||||
const authData = readAuthData(provider, accountId);
|
||||
if (!authData) {
|
||||
const error = 'Auth file not found for account';
|
||||
|
||||
@@ -12,22 +12,40 @@ import {
|
||||
getAllAccountsSummary,
|
||||
setDefaultAccount as setCliproxyDefault,
|
||||
removeAccount as removeCliproxyAccount,
|
||||
bulkPauseAccounts,
|
||||
bulkResumeAccounts,
|
||||
soloAccount,
|
||||
} from '../../cliproxy/account-manager';
|
||||
import { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
|
||||
const router = Router();
|
||||
const registry = new ProfileRegistry();
|
||||
|
||||
/** Valid CLIProxy providers */
|
||||
const VALID_PROVIDERS: CLIProxyProvider[] = [
|
||||
'gemini',
|
||||
'codex',
|
||||
'agy',
|
||||
'qwen',
|
||||
'iflow',
|
||||
'kiro',
|
||||
'ghcp',
|
||||
];
|
||||
|
||||
/** Check if provider is valid */
|
||||
function isValidProvider(provider: string): provider is CLIProxyProvider {
|
||||
return VALID_PROVIDERS.includes(provider as CLIProxyProvider);
|
||||
}
|
||||
|
||||
/** Parse CLIProxy account key format: "provider:accountId" */
|
||||
function parseCliproxyKey(key: string): { provider: CLIProxyProvider; accountId: string } | null {
|
||||
const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp'];
|
||||
const colonIndex = key.indexOf(':');
|
||||
if (colonIndex === -1) return null;
|
||||
|
||||
const provider = key.slice(0, colonIndex) as CLIProxyProvider;
|
||||
const accountId = key.slice(colonIndex + 1);
|
||||
|
||||
if (!providers.includes(provider) || !accountId) return null;
|
||||
if (!isValidProvider(provider) || !accountId) return null;
|
||||
return { provider, accountId };
|
||||
}
|
||||
|
||||
@@ -203,4 +221,107 @@ router.delete('/:name', (req: Request, res: Response): void => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/accounts/bulk-pause - Bulk pause multiple accounts
|
||||
*/
|
||||
router.post('/bulk-pause', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { provider, accountIds } = req.body;
|
||||
|
||||
if (!provider || !Array.isArray(accountIds)) {
|
||||
res.status(400).json({ error: 'Missing required fields: provider and accountIds (array)' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidProvider(provider)) {
|
||||
res.status(400).json({ error: `Invalid provider: ${provider}` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow empty arrays - return early success
|
||||
if (accountIds.length === 0) {
|
||||
res.json({ succeeded: [], failed: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate accountIds are non-empty strings
|
||||
const invalidIds = accountIds.filter((id) => typeof id !== 'string' || id.trim().length === 0);
|
||||
if (invalidIds.length > 0) {
|
||||
res.status(400).json({ error: 'Invalid accountIds: must be non-empty strings' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = bulkPauseAccounts(provider, accountIds);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/accounts/bulk-resume - Bulk resume multiple accounts
|
||||
*/
|
||||
router.post('/bulk-resume', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { provider, accountIds } = req.body;
|
||||
|
||||
if (!provider || !Array.isArray(accountIds)) {
|
||||
res.status(400).json({ error: 'Missing required fields: provider and accountIds (array)' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidProvider(provider)) {
|
||||
res.status(400).json({ error: `Invalid provider: ${provider}` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow empty arrays - return early success
|
||||
if (accountIds.length === 0) {
|
||||
res.json({ succeeded: [], failed: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate accountIds are non-empty strings
|
||||
const invalidIds = accountIds.filter((id) => typeof id !== 'string' || id.trim().length === 0);
|
||||
if (invalidIds.length > 0) {
|
||||
res.status(400).json({ error: 'Invalid accountIds: must be non-empty strings' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = bulkResumeAccounts(provider, accountIds);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/accounts/solo - Solo mode: activate one account, pause all others
|
||||
*/
|
||||
router.post('/solo', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { provider, accountId } = req.body;
|
||||
|
||||
if (!provider || !accountId) {
|
||||
res.status(400).json({ error: 'Missing required fields: provider and accountId' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidProvider(provider)) {
|
||||
res.status(400).json({ error: `Invalid provider: ${provider}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await soloAccount(provider, accountId);
|
||||
if (!result) {
|
||||
res.status(404).json({ error: `Account not found: ${accountId}` });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -4,15 +4,18 @@
|
||||
|
||||
import {
|
||||
cn,
|
||||
sortModelsByPriority,
|
||||
formatResetTime,
|
||||
getEarliestResetTime,
|
||||
getClaudeResetTime,
|
||||
getMinClaudeQuota,
|
||||
getModelsWithTiers,
|
||||
groupModelsByTier,
|
||||
type ModelTier,
|
||||
} from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { GripVertical, Loader2, Clock, Pause } from 'lucide-react';
|
||||
import { GripVertical, Loader2, Clock, Pause, Play } from 'lucide-react';
|
||||
import { useAccountQuota } from '@/hooks/use-cliproxy-stats';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import type { AccountData, DragOffset } from './types';
|
||||
import { cleanEmail } from './utils';
|
||||
@@ -34,6 +37,8 @@ interface AccountCardProps {
|
||||
onPointerDown: (e: React.PointerEvent) => void;
|
||||
onPointerMove: (e: React.PointerEvent) => void;
|
||||
onPointerUp: () => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
isPausingAccount?: boolean;
|
||||
}
|
||||
|
||||
const BORDER_SIDE_MAP: Record<Zone, string> = {
|
||||
@@ -77,6 +82,8 @@ export function AccountCard({
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPauseToggle,
|
||||
isPausingAccount,
|
||||
}: AccountCardProps) {
|
||||
const borderSide = BORDER_SIDE_MAP[zone];
|
||||
const borderColor = getBorderColorStyle(zone, account.color);
|
||||
@@ -109,7 +116,8 @@ export function AccountCard({
|
||||
borderSide,
|
||||
'select-none touch-none',
|
||||
isHovered && 'bg-muted/50 dark:bg-zinc-800/60',
|
||||
isDragging && 'cursor-grabbing shadow-xl scale-105 z-50'
|
||||
isDragging && 'cursor-grabbing shadow-xl scale-105 z-50',
|
||||
account.paused && 'opacity-60'
|
||||
)}
|
||||
style={{
|
||||
...borderColor,
|
||||
@@ -117,7 +125,7 @@ export function AccountCard({
|
||||
}}
|
||||
>
|
||||
<GripVertical className="absolute top-2 right-2 w-4 h-4 text-muted-foreground/40" />
|
||||
<div className="flex justify-between items-start mb-1 mr-4">
|
||||
<div className="flex items-center gap-1.5 mb-1 mr-4">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]',
|
||||
@@ -126,6 +134,39 @@ export function AccountCard({
|
||||
>
|
||||
{cleanEmail(account.email)}
|
||||
</span>
|
||||
{onPauseToggle && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
'transition-all rounded-full',
|
||||
account.paused ? 'bg-amber-500/20 hover:bg-amber-500/30' : 'hover:bg-muted'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPauseToggle(account.id, !account.paused);
|
||||
}}
|
||||
disabled={isPausingAccount}
|
||||
>
|
||||
{isPausingAccount ? (
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||
) : account.paused ? (
|
||||
<Play className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
|
||||
) : (
|
||||
<Pause className="w-2.5 h-2.5 text-muted-foreground/50 hover:text-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{account.paused ? 'Resume account' : 'Pause account'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
<AccountCardStats
|
||||
success={account.successCount}
|
||||
@@ -180,16 +221,37 @@ export function AccountCard({
|
||||
<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 || []);
|
||||
const tiered = getModelsWithTiers(quota?.models || []);
|
||||
const groups = groupModelsByTier(tiered);
|
||||
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
|
||||
return tierOrder.map((tier, idx) => {
|
||||
const models = groups.get(tier);
|
||||
if (!models || models.length === 0) return null;
|
||||
const isFirst = tierOrder
|
||||
.slice(0, idx)
|
||||
.every((t) => !groups.get(t)?.length);
|
||||
return (
|
||||
<div key={tier}>
|
||||
{!isFirst && <div className="border-t border-border/40 my-1" />}
|
||||
{models.map((m) => (
|
||||
<div key={m.name} className="flex justify-between gap-4">
|
||||
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
|
||||
{m.displayName}
|
||||
</span>
|
||||
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
|
||||
{m.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
{(() => {
|
||||
const resetTime = getClaudeResetTime(quota?.models || []);
|
||||
return resetTime ? (
|
||||
<div className="flex items-center gap-1.5 mt-2 pt-2 border-t border-border/50">
|
||||
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
|
||||
<Clock className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-blue-400 font-medium">
|
||||
Resets {formatResetTime(resetTime)}
|
||||
@@ -202,16 +264,9 @@ export function AccountCard({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : quota?.error ? (
|
||||
quota.error === 'Account is paused' ? (
|
||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
|
||||
<Pause className="w-3 h-3" />
|
||||
<span className="text-[9px] font-medium uppercase tracking-wide">Paused</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[8px] text-muted-foreground/60 truncate" title={quota.error}>
|
||||
{quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error}
|
||||
</div>
|
||||
)
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -22,7 +22,12 @@ import { FlowVizHeader } from './flow-viz-header';
|
||||
// Re-export types for backward compatibility
|
||||
export type { AccountData, ProviderData, AccountFlowVizProps, ConnectionEvent } from './types';
|
||||
|
||||
export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
|
||||
export function AccountFlowViz({
|
||||
providerData,
|
||||
onBack,
|
||||
onPauseToggle,
|
||||
isPausingAccount,
|
||||
}: AccountFlowVizProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [hoveredAccount, setHoveredAccount] = useState<number | null>(null);
|
||||
@@ -112,6 +117,8 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
|
||||
onPointerDown={(e) => handlePointerDown(account.id, e)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPauseToggle={onPauseToggle}
|
||||
isPausingAccount={isPausingAccount}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface AccountData {
|
||||
failureCount: number;
|
||||
lastUsedAt?: string;
|
||||
color: string;
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderData {
|
||||
@@ -28,6 +29,8 @@ export interface ProviderData {
|
||||
export interface AccountFlowVizProps {
|
||||
providerData: ProviderData;
|
||||
onBack?: () => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
isPausingAccount?: boolean;
|
||||
}
|
||||
|
||||
export interface ConnectionEvent {
|
||||
|
||||
@@ -27,13 +27,16 @@ import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
FolderCode,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
cn,
|
||||
sortModelsByPriority,
|
||||
formatResetTime,
|
||||
getEarliestResetTime,
|
||||
getClaudeResetTime,
|
||||
getMinClaudeQuota,
|
||||
getModelsWithTiers,
|
||||
groupModelsByTier,
|
||||
type ModelTier,
|
||||
} from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||
@@ -97,6 +100,9 @@ export function AccountItem({
|
||||
isPausingAccount,
|
||||
privacyMode,
|
||||
showQuota,
|
||||
selectable,
|
||||
selected,
|
||||
onSelectChange,
|
||||
}: AccountItemProps) {
|
||||
// Fetch runtime stats to get actual lastUsedAt (more accurate than file state)
|
||||
const { data: stats } = useCliproxyStats(showQuota);
|
||||
@@ -117,17 +123,62 @@ export function AccountItem({
|
||||
|
||||
// Get earliest reset time
|
||||
const nextReset =
|
||||
quota?.success && quota.models.length > 0 ? getEarliestResetTime(quota.models) : null;
|
||||
quota?.success && quota.models.length > 0 ? getClaudeResetTime(quota.models) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-2 p-3 rounded-lg border transition-colors',
|
||||
account.isDefault ? 'border-primary/30 bg-primary/5' : 'border-border hover:bg-muted/30'
|
||||
account.isDefault ? 'border-primary/30 bg-primary/5' : 'border-border hover:bg-muted/30',
|
||||
account.paused && 'opacity-75',
|
||||
selected && 'ring-2 ring-primary/50 bg-primary/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Selection checkbox for bulk actions */}
|
||||
{selectable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectChange?.(!selected)}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-5 h-5 rounded border-2 transition-colors shrink-0',
|
||||
selected
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'border-muted-foreground/30 hover:border-primary/50'
|
||||
)}
|
||||
aria-label={selected ? 'Deselect account' : 'Select account'}
|
||||
>
|
||||
{selected && <Check className="w-3 h-3" />}
|
||||
</button>
|
||||
)}
|
||||
{/* Pause/Resume toggle button - visible left of avatar */}
|
||||
{onPauseToggle && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => onPauseToggle(!account.paused)}
|
||||
disabled={isPausingAccount}
|
||||
>
|
||||
{isPausingAccount ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : account.paused ? (
|
||||
<Play className="w-4 h-4 text-emerald-500" />
|
||||
) : (
|
||||
<Pause className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{account.paused ? 'Resume account' : 'Pause account'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-full',
|
||||
@@ -239,24 +290,6 @@ export function AccountItem({
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onPauseToggle && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onPauseToggle(!account.paused)}
|
||||
disabled={isPausingAccount}
|
||||
>
|
||||
{account.paused ? (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{isPausingAccount ? 'Resuming...' : 'Resume account'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="w-4 h-4 mr-2" />
|
||||
{isPausingAccount ? 'Pausing...' : 'Pause account'}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={onRemove}
|
||||
@@ -318,14 +351,35 @@ export function AccountItem({
|
||||
<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>
|
||||
))}
|
||||
{(() => {
|
||||
const tiered = getModelsWithTiers(quota?.models || []);
|
||||
const groups = groupModelsByTier(tiered);
|
||||
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
|
||||
return tierOrder.map((tier, idx) => {
|
||||
const models = groups.get(tier);
|
||||
if (!models || models.length === 0) return null;
|
||||
const isFirst = tierOrder
|
||||
.slice(0, idx)
|
||||
.every((t) => !groups.get(t)?.length);
|
||||
return (
|
||||
<div key={tier}>
|
||||
{!isFirst && <div className="border-t border-border/40 my-1" />}
|
||||
{models.map((m) => (
|
||||
<div key={m.name} className="flex justify-between gap-4">
|
||||
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
|
||||
{m.displayName}
|
||||
</span>
|
||||
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
|
||||
{m.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
{nextReset && (
|
||||
<div className="flex items-center gap-1.5 mt-2 pt-2 border-t border-border/50">
|
||||
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
|
||||
<Clock className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-blue-400 font-medium">
|
||||
Resets {formatResetTime(nextReset)}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
/**
|
||||
* Accounts Section Component
|
||||
* Manages connected OAuth accounts for a provider
|
||||
* Manages connected OAuth accounts for a provider with multi-select bulk actions
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { User, Plus, Globe } from 'lucide-react';
|
||||
import { AccountItem } from './account-item';
|
||||
import { BulkActionBar } from './bulk-action-bar';
|
||||
import type { OAuthAccount } from '@/lib/api-client';
|
||||
|
||||
interface AccountsSectionProps {
|
||||
@@ -16,9 +19,21 @@ interface AccountsSectionProps {
|
||||
onSetDefault: (accountId: string) => void;
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
/** Solo mode: activate one account, pause all others */
|
||||
onSoloMode?: (accountId: string) => void;
|
||||
/** Bulk pause multiple accounts */
|
||||
onBulkPause?: (accountIds: string[]) => void;
|
||||
/** Bulk resume multiple accounts */
|
||||
onBulkResume?: (accountIds: string[]) => void;
|
||||
isRemovingAccount?: boolean;
|
||||
/** Pause/resume mutation in progress */
|
||||
isPausingAccount?: boolean;
|
||||
/** Solo mode mutation in progress */
|
||||
isSoloingAccount?: boolean;
|
||||
/** Bulk pause mutation in progress */
|
||||
isBulkPausing?: boolean;
|
||||
/** Bulk resume mutation in progress */
|
||||
isBulkResuming?: boolean;
|
||||
privacyMode?: boolean;
|
||||
/** Show quota bars for accounts (only applicable for 'agy' provider) */
|
||||
showQuota?: boolean;
|
||||
@@ -35,8 +50,14 @@ export function AccountsSection({
|
||||
onSetDefault,
|
||||
onRemoveAccount,
|
||||
onPauseToggle,
|
||||
onSoloMode,
|
||||
onBulkPause,
|
||||
onBulkResume,
|
||||
isRemovingAccount,
|
||||
isPausingAccount,
|
||||
isSoloingAccount,
|
||||
isBulkPausing,
|
||||
isBulkResuming,
|
||||
privacyMode,
|
||||
showQuota,
|
||||
isKiro,
|
||||
@@ -44,10 +65,80 @@ export function AccountsSection({
|
||||
onKiroNoIncognitoChange,
|
||||
kiroSettingsLoading,
|
||||
}: AccountsSectionProps) {
|
||||
// Multi-select state - raw selection (may contain stale IDs)
|
||||
const [rawSelectedIds, setRawSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Derive valid selections by filtering out stale IDs
|
||||
const accountIds = useMemo(() => new Set(accounts.map((a) => a.id)), [accounts]);
|
||||
const selectedIds = useMemo(
|
||||
() => new Set([...rawSelectedIds].filter((id) => accountIds.has(id))),
|
||||
[rawSelectedIds, accountIds]
|
||||
);
|
||||
|
||||
// Enable multi-select when bulk actions are available
|
||||
const isSelectable = !!(onBulkPause && onBulkResume);
|
||||
|
||||
// Computed selection state
|
||||
const selectedCount = selectedIds.size;
|
||||
const allSelected = accounts.length > 0 && selectedIds.size === accounts.length;
|
||||
const someSelected = selectedIds.size > 0 && selectedIds.size < accounts.length;
|
||||
|
||||
// Selection handlers
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setRawSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setRawSelectedIds(new Set(accounts.map((a) => a.id)));
|
||||
}, [accounts]);
|
||||
|
||||
const deselectAll = useCallback(() => {
|
||||
setRawSelectedIds(new Set());
|
||||
}, []);
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (allSelected) {
|
||||
deselectAll();
|
||||
} else {
|
||||
selectAll();
|
||||
}
|
||||
}, [allSelected, selectAll, deselectAll]);
|
||||
|
||||
// Bulk action handlers
|
||||
const handleBulkPause = useCallback(() => {
|
||||
if (onBulkPause && selectedIds.size > 0) {
|
||||
onBulkPause(Array.from(selectedIds));
|
||||
// Clear selection after action (will be invalidated by mutation success)
|
||||
setRawSelectedIds(new Set());
|
||||
}
|
||||
}, [onBulkPause, selectedIds]);
|
||||
|
||||
const handleBulkResume = useCallback(() => {
|
||||
if (onBulkResume && selectedIds.size > 0) {
|
||||
onBulkResume(Array.from(selectedIds));
|
||||
setRawSelectedIds(new Set());
|
||||
}
|
||||
}, [onBulkResume, selectedIds]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
{/* Select All checkbox */}
|
||||
{isSelectable && accounts.length > 0 && (
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
aria-label="Select all accounts"
|
||||
className={someSelected ? 'data-[state=checked]:bg-primary/50' : ''}
|
||||
{...(someSelected ? { 'data-state': 'indeterminate' } : {})}
|
||||
/>
|
||||
)}
|
||||
<User className="w-4 h-4" />
|
||||
Accounts
|
||||
{accounts.length > 0 && (
|
||||
@@ -62,6 +153,18 @@ export function AccountsSection({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Bulk Action Bar - shows when accounts selected (positioned at TOP for visibility) */}
|
||||
{isSelectable && selectedCount > 0 && (
|
||||
<BulkActionBar
|
||||
selectedCount={selectedCount}
|
||||
onPauseSelected={handleBulkPause}
|
||||
onResumeSelected={handleBulkResume}
|
||||
onClearSelection={deselectAll}
|
||||
isPausing={!!isBulkPausing}
|
||||
isResuming={!!isBulkResuming}
|
||||
/>
|
||||
)}
|
||||
|
||||
{accounts.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{accounts.map((account) => (
|
||||
@@ -73,10 +176,15 @@ export function AccountsSection({
|
||||
onPauseToggle={
|
||||
onPauseToggle ? (paused) => onPauseToggle(account.id, paused) : undefined
|
||||
}
|
||||
onSoloMode={onSoloMode ? () => onSoloMode(account.id) : undefined}
|
||||
isRemoving={isRemovingAccount}
|
||||
isPausingAccount={isPausingAccount}
|
||||
isSoloingAccount={isSoloingAccount}
|
||||
privacyMode={privacyMode}
|
||||
showQuota={showQuota}
|
||||
selectable={isSelectable}
|
||||
selected={selectedIds.has(account.id)}
|
||||
onSelectChange={() => toggleSelect(account.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Bulk Action Bar Component
|
||||
* Appears when accounts are selected for bulk pause/resume operations
|
||||
*/
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Pause, Play, Loader2 } from 'lucide-react';
|
||||
|
||||
interface BulkActionBarProps {
|
||||
selectedCount: number;
|
||||
onPauseSelected: () => void;
|
||||
onResumeSelected: () => void;
|
||||
onClearSelection: () => void;
|
||||
isPausing: boolean;
|
||||
isResuming: boolean;
|
||||
}
|
||||
|
||||
export function BulkActionBar({
|
||||
selectedCount,
|
||||
onPauseSelected,
|
||||
onResumeSelected,
|
||||
onClearSelection,
|
||||
isPausing,
|
||||
isResuming,
|
||||
}: BulkActionBarProps) {
|
||||
// Show bar when at least 1 account is selected (per validation decision)
|
||||
if (selectedCount < 1) return null;
|
||||
|
||||
const isLoading = isPausing || isResuming;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-2 bg-muted/50 rounded-lg border mt-2">
|
||||
<span className="text-sm text-muted-foreground">{selectedCount} selected</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs h-6 px-2"
|
||||
onClick={onClearSelection}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs gap-1"
|
||||
onClick={onPauseSelected}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isPausing ? <Loader2 className="w-3 h-3 animate-spin" /> : <Pause className="w-3 h-3" />}
|
||||
Pause Selected
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs gap-1"
|
||||
onClick={onResumeSelected}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isResuming ? <Loader2 className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
||||
Resume Selected
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,8 +39,14 @@ export function ProviderEditor({
|
||||
onSetDefault,
|
||||
onRemoveAccount,
|
||||
onPauseToggle,
|
||||
onSoloMode,
|
||||
onBulkPause,
|
||||
onBulkResume,
|
||||
isRemovingAccount,
|
||||
isPausingAccount,
|
||||
isSoloingAccount,
|
||||
isBulkPausing,
|
||||
isBulkResuming,
|
||||
}: ProviderEditorProps) {
|
||||
const [customPresetOpen, setCustomPresetOpen] = useState(false);
|
||||
const { privacyMode } = usePrivacy();
|
||||
@@ -203,8 +209,14 @@ export function ProviderEditor({
|
||||
onSetDefault={onSetDefault}
|
||||
onRemoveAccount={onRemoveAccount}
|
||||
onPauseToggle={onPauseToggle}
|
||||
onSoloMode={onSoloMode}
|
||||
onBulkPause={onBulkPause}
|
||||
onBulkResume={onBulkResume}
|
||||
isRemovingAccount={isRemovingAccount}
|
||||
isPausingAccount={isPausingAccount}
|
||||
isSoloingAccount={isSoloingAccount}
|
||||
isBulkPausing={isBulkPausing}
|
||||
isBulkResuming={isBulkResuming}
|
||||
privacyMode={privacyMode}
|
||||
isRemoteMode={isRemoteMode}
|
||||
/>
|
||||
|
||||
@@ -37,9 +37,21 @@ interface ModelConfigTabProps {
|
||||
onSetDefault: (accountId: string) => void;
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
/** Solo mode: activate one account, pause all others */
|
||||
onSoloMode?: (accountId: string) => void;
|
||||
isRemovingAccount?: boolean;
|
||||
/** Pause/resume mutation in progress */
|
||||
isPausingAccount?: boolean;
|
||||
/** Solo mode mutation in progress */
|
||||
isSoloingAccount?: boolean;
|
||||
/** Bulk pause multiple accounts */
|
||||
onBulkPause?: (accountIds: string[]) => void;
|
||||
/** Bulk resume multiple accounts */
|
||||
onBulkResume?: (accountIds: string[]) => void;
|
||||
/** Bulk pause mutation in progress */
|
||||
isBulkPausing?: boolean;
|
||||
/** Bulk resume mutation in progress */
|
||||
isBulkResuming?: boolean;
|
||||
privacyMode?: boolean;
|
||||
/** True if connected to remote CLIProxy (quota not available) */
|
||||
isRemoteMode?: boolean;
|
||||
@@ -64,8 +76,14 @@ export function ModelConfigTab({
|
||||
onSetDefault,
|
||||
onRemoveAccount,
|
||||
onPauseToggle,
|
||||
onSoloMode,
|
||||
onBulkPause,
|
||||
onBulkResume,
|
||||
isRemovingAccount,
|
||||
isPausingAccount,
|
||||
isSoloingAccount,
|
||||
isBulkPausing,
|
||||
isBulkResuming,
|
||||
privacyMode,
|
||||
isRemoteMode,
|
||||
}: ModelConfigTabProps) {
|
||||
@@ -140,8 +158,14 @@ export function ModelConfigTab({
|
||||
onSetDefault={onSetDefault}
|
||||
onRemoveAccount={onRemoveAccount}
|
||||
onPauseToggle={onPauseToggle}
|
||||
onSoloMode={onSoloMode}
|
||||
onBulkPause={onBulkPause}
|
||||
onBulkResume={onBulkResume}
|
||||
isRemovingAccount={isRemovingAccount}
|
||||
isPausingAccount={isPausingAccount}
|
||||
isSoloingAccount={isSoloingAccount}
|
||||
isBulkPausing={isBulkPausing}
|
||||
isBulkResuming={isBulkResuming}
|
||||
privacyMode={privacyMode}
|
||||
showQuota={provider === 'agy' && !isRemoteMode}
|
||||
isKiro={isKiro}
|
||||
|
||||
@@ -31,9 +31,21 @@ export interface ProviderEditorProps {
|
||||
onSetDefault: (accountId: string) => void;
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
/** Solo mode: activate one account, pause all others */
|
||||
onSoloMode?: (accountId: string) => void;
|
||||
/** Bulk pause multiple accounts */
|
||||
onBulkPause?: (accountIds: string[]) => void;
|
||||
/** Bulk resume multiple accounts */
|
||||
onBulkResume?: (accountIds: string[]) => void;
|
||||
isRemovingAccount?: boolean;
|
||||
/** Pause/resume mutation in progress */
|
||||
isPausingAccount?: boolean;
|
||||
/** Solo mode mutation in progress */
|
||||
isSoloingAccount?: boolean;
|
||||
/** Bulk pause mutation in progress */
|
||||
isBulkPausing?: boolean;
|
||||
/** Bulk resume mutation in progress */
|
||||
isBulkResuming?: boolean;
|
||||
}
|
||||
|
||||
export interface AccountItemProps {
|
||||
@@ -41,12 +53,22 @@ export interface AccountItemProps {
|
||||
onSetDefault: () => void;
|
||||
onRemove: () => void;
|
||||
onPauseToggle?: (paused: boolean) => void;
|
||||
/** Solo mode: activate this account, pause all others */
|
||||
onSoloMode?: () => void;
|
||||
isRemoving?: boolean;
|
||||
/** Pause/resume mutation in progress */
|
||||
isPausingAccount?: boolean;
|
||||
/** Solo mode mutation in progress */
|
||||
isSoloingAccount?: boolean;
|
||||
privacyMode?: boolean;
|
||||
/** Show quota bar (only for 'agy' provider) */
|
||||
showQuota?: boolean;
|
||||
/** Enable checkbox for multi-select */
|
||||
selectable?: boolean;
|
||||
/** Whether this account is currently selected */
|
||||
selected?: boolean;
|
||||
/** Called when checkbox is toggled */
|
||||
onSelectChange?: (selected: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ModelMappingValues {
|
||||
|
||||
@@ -110,7 +110,7 @@ export function ProviderCard({
|
||||
return (
|
||||
<div key={acc.id} className="relative">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
className={cn('w-2 h-2 rounded-full', acc.paused && 'opacity-50')}
|
||||
style={{ backgroundColor: acc.color }}
|
||||
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
|
||||
/>
|
||||
|
||||
@@ -100,6 +100,7 @@ export function useAuthMonitorData(): AuthMonitorData {
|
||||
lastUsedAt: realStats?.lastUsedAt ?? account.lastUsedAt,
|
||||
color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length],
|
||||
projectId: account.projectId,
|
||||
paused: account.paused,
|
||||
};
|
||||
accountsList.push(row);
|
||||
providerData.accounts.push(row);
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
* Uses glass panel aesthetic with hover interactions and glow effects
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { STATUS_COLORS } from '@/lib/utils';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AccountFlowViz } from '@/components/account-flow-viz';
|
||||
import { usePrivacy } from '@/contexts/privacy-context';
|
||||
import { usePauseAccount, useResumeAccount } from '@/hooks/use-cliproxy';
|
||||
import { Activity, CheckCircle2, XCircle, Radio } from 'lucide-react';
|
||||
|
||||
import { useAuthMonitorData } from './hooks';
|
||||
@@ -16,6 +17,8 @@ import { LivePulse } from './components/live-pulse';
|
||||
import { ProviderCard } from './components/provider-card';
|
||||
import { SummaryCard } from './components/summary-card';
|
||||
|
||||
const STORAGE_KEY = 'auth-monitor-selected-provider';
|
||||
|
||||
export function AuthMonitor() {
|
||||
const {
|
||||
accounts,
|
||||
@@ -30,14 +33,52 @@ export function AuthMonitor() {
|
||||
} = useAuthMonitorData();
|
||||
|
||||
const { privacyMode } = usePrivacy();
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
||||
|
||||
// Persist selected provider in localStorage
|
||||
const [selectedProvider, setSelectedProviderState] = useState<string | null>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem(STORAGE_KEY);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Wrapper to persist selection
|
||||
const setSelectedProvider = (provider: string | null) => {
|
||||
setSelectedProviderState(provider);
|
||||
if (provider) {
|
||||
localStorage.setItem(STORAGE_KEY, provider);
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
// Effective provider: prefer saved (if still exists) > null (show grid)
|
||||
const effectiveProvider = useMemo(() => {
|
||||
if (selectedProvider && providerStats.some((p) => p.provider === selectedProvider)) {
|
||||
return selectedProvider;
|
||||
}
|
||||
return null;
|
||||
}, [selectedProvider, providerStats]);
|
||||
const [hoveredProvider, setHoveredProvider] = useState<string | null>(null);
|
||||
|
||||
// Account control mutations for flow viz
|
||||
const pauseMutation = usePauseAccount();
|
||||
const resumeMutation = useResumeAccount();
|
||||
|
||||
// Get selected provider data for detail view
|
||||
const selectedProviderData = selectedProvider
|
||||
? providerStats.find((ps) => ps.provider === selectedProvider)
|
||||
const selectedProviderData = effectiveProvider
|
||||
? providerStats.find((ps) => ps.provider === effectiveProvider)
|
||||
: null;
|
||||
|
||||
const handlePauseToggle = (accountId: string, paused: boolean) => {
|
||||
if (!effectiveProvider || pauseMutation.isPending || resumeMutation.isPending) return;
|
||||
if (paused) {
|
||||
pauseMutation.mutate({ provider: effectiveProvider, accountId });
|
||||
} else {
|
||||
resumeMutation.mutate({ provider: effectiveProvider, accountId });
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border overflow-hidden font-mono text-[13px] bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm">
|
||||
@@ -121,6 +162,8 @@ export function AuthMonitor() {
|
||||
<AccountFlowViz
|
||||
providerData={selectedProviderData}
|
||||
onBack={() => setSelectedProvider(null)}
|
||||
onPauseToggle={handlePauseToggle}
|
||||
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-6">
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface AccountRow {
|
||||
color: string;
|
||||
/** GCP Project ID (Antigravity only) - read-only */
|
||||
projectId?: string;
|
||||
/** Whether account is paused (skipped in quota rotation) */
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderStats {
|
||||
|
||||
@@ -127,6 +127,7 @@ export function usePauseAccount() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] });
|
||||
toast.success('Account paused');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
@@ -144,6 +145,7 @@ export function useResumeAccount() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] });
|
||||
toast.success('Account resumed');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
@@ -152,6 +154,77 @@ export function useResumeAccount() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSoloAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) =>
|
||||
api.cliproxy.accounts.solo(provider, accountId),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] });
|
||||
const pausedCount = data.paused.length;
|
||||
toast.success(
|
||||
`Solo mode: paused ${pausedCount} other account${pausedCount !== 1 ? 's' : ''}`
|
||||
);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBulkPauseAccounts() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ provider, accountIds }: { provider: string; accountIds: string[] }) =>
|
||||
api.cliproxy.accounts.bulkPause(provider, accountIds),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] });
|
||||
toast.success(
|
||||
`Paused ${data.succeeded.length} account${data.succeeded.length !== 1 ? 's' : ''}`
|
||||
);
|
||||
if (data.failed.length > 0) {
|
||||
toast.warning(
|
||||
`${data.failed.length} account${data.failed.length !== 1 ? 's' : ''} failed to pause`
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBulkResumeAccounts() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ provider, accountIds }: { provider: string; accountIds: string[] }) =>
|
||||
api.cliproxy.accounts.bulkResume(provider, accountIds),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] });
|
||||
toast.success(
|
||||
`Resumed ${data.succeeded.length} account${data.succeeded.length !== 1 ? 's' : ''}`
|
||||
);
|
||||
if (data.failed.length > 0) {
|
||||
toast.warning(
|
||||
`${data.failed.length} account${data.failed.length !== 1 ? 's' : ''} failed to resume`
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// OAuth flow hook
|
||||
export function useStartAuth() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -415,6 +415,30 @@ export const api = {
|
||||
`/cliproxy/auth/accounts/${provider}/${accountId}/resume`,
|
||||
{ method: 'POST' }
|
||||
),
|
||||
/** Solo mode: activate one account, pause all others */
|
||||
solo: (provider: string, accountId: string) =>
|
||||
request<{ activated: string; paused: string[] }>('/accounts/solo', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider, accountId }),
|
||||
}),
|
||||
/** Bulk pause multiple accounts */
|
||||
bulkPause: (provider: string, accountIds: string[]) =>
|
||||
request<{ succeeded: string[]; failed: Array<{ id: string; reason: string }> }>(
|
||||
'/accounts/bulk-pause',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider, accountIds }),
|
||||
}
|
||||
),
|
||||
/** Bulk resume multiple accounts */
|
||||
bulkResume: (provider: string, accountIds: string[]) =>
|
||||
request<{ succeeded: string[]; failed: Array<{ id: string; reason: string }> }>(
|
||||
'/accounts/bulk-resume',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider, accountIds }),
|
||||
}
|
||||
),
|
||||
},
|
||||
// OAuth flow
|
||||
auth: {
|
||||
|
||||
+176
-18
@@ -56,18 +56,29 @@ export function getProviderColor(provider: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort models with Claude models first, then alphabetically
|
||||
* Prioritizes: Claude > Gemini > GPT > Other (alphabetically)
|
||||
* Sort models by tier: Primary (Claude/GPT) > Gemini 3 Pro > Gemini 2.5 > Others
|
||||
* Within each tier, sorts alphabetically by display name
|
||||
*/
|
||||
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;
|
||||
|
||||
// Tier 0: Primary models (Claude + GPT) - weekly limits, most valuable
|
||||
if (name.includes('claude') || name.includes('gpt')) return 0;
|
||||
|
||||
// Tier 1: Gemini 3 Pro models - high capability
|
||||
if (name.includes('gemini 3') || name.includes('gemini-3')) return 1;
|
||||
|
||||
// Tier 2: Gemini 2.5 Pro/Flash models - mid tier
|
||||
if (name.includes('gemini 2.5') || name.includes('gemini-2.5')) return 2;
|
||||
|
||||
// Tier 3: Other Gemini models
|
||||
if (name.includes('gemini')) return 3;
|
||||
|
||||
// Tier 4: Everything else
|
||||
return 4;
|
||||
};
|
||||
|
||||
return [...models].sort((a, b) => {
|
||||
@@ -81,7 +92,7 @@ export function sortModelsByPriority<T extends { name: string; displayName?: str
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reset time as relative time (e.g., "in 2h 30m")
|
||||
* Format reset time - relative for <24h, absolute date for >=24h (weekly limits)
|
||||
*/
|
||||
export function formatResetTime(resetTime: string | null): string | null {
|
||||
if (!resetTime) return null;
|
||||
@@ -92,8 +103,19 @@ export function formatResetTime(resetTime: string | null): string | null {
|
||||
if (diff <= 0) return 'soon';
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
// Weekly/long resets: show absolute date (e.g., "01/27, 12:07")
|
||||
if (hours >= 24) {
|
||||
return reset.toLocaleDateString(undefined, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
// Daily resets: show relative time
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
if (hours > 0) return `in ${hours}h ${minutes}m`;
|
||||
return `in ${minutes}m`;
|
||||
} catch {
|
||||
@@ -118,25 +140,161 @@ export function getEarliestResetTime<T extends { resetTime: string | null }>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the minimum quota percentage from Claude models (primary usage).
|
||||
* Falls back to minimum of all models if no Claude models exist.
|
||||
* Returns null if no valid models or quota data.
|
||||
* Filter to get Claude/GPT models (primary models we care about for quota)
|
||||
* These have weekly limits vs Gemini's daily limits
|
||||
*/
|
||||
function filterPrimaryModels<T extends { name: string; displayName?: string }>(models: T[]): T[] {
|
||||
return models.filter((m) => {
|
||||
const name = (m.displayName || m.name || '').toLowerCase();
|
||||
return name.includes('claude') || name.includes('gpt');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the minimum quota percentage from Claude/GPT models.
|
||||
* Returns 0 if Claude/GPT models are missing (exhausted/removed from API response).
|
||||
* Only returns null if no models at all.
|
||||
*/
|
||||
export function getMinClaudeQuota<
|
||||
T extends { name: string; displayName?: string; percentage: number },
|
||||
>(models: T[]): number | null {
|
||||
if (models.length === 0) return null;
|
||||
|
||||
const claudeModels = models.filter((m) => {
|
||||
const name = (m.displayName || m.name || '').toLowerCase();
|
||||
return name.includes('claude');
|
||||
});
|
||||
const primaryModels = filterPrimaryModels(models);
|
||||
|
||||
const targetModels = claudeModels.length > 0 ? claudeModels : models;
|
||||
const percentages = targetModels
|
||||
// If no Claude/GPT models in response, they're exhausted (0%)
|
||||
if (primaryModels.length === 0) return 0;
|
||||
|
||||
const percentages = primaryModels
|
||||
.map((m) => m.percentage)
|
||||
.filter((p) => typeof p === 'number' && isFinite(p));
|
||||
|
||||
if (percentages.length === 0) return null;
|
||||
if (percentages.length === 0) return 0;
|
||||
return Math.min(...percentages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reset time for Claude/GPT models (primary models).
|
||||
* Returns null only if no primary models present in response.
|
||||
*/
|
||||
export function getClaudeResetTime<
|
||||
T extends { name: string; displayName?: string; resetTime: string | null },
|
||||
>(models: T[]): string | null {
|
||||
if (models.length === 0) return null;
|
||||
|
||||
const primaryModels = filterPrimaryModels(models);
|
||||
if (primaryModels.length === 0) return null;
|
||||
|
||||
return primaryModels.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
|
||||
);
|
||||
}
|
||||
|
||||
// Known primary models to show when exhausted (removed from API response)
|
||||
const KNOWN_PRIMARY_MODELS = [
|
||||
{ name: 'claude-opus-4-5-thinking', displayName: 'Claude Opus 4.5 (Thinking)' },
|
||||
{ name: 'claude-sonnet-4-5', displayName: 'Claude Sonnet 4.5' },
|
||||
{ name: 'claude-sonnet-4-5-thinking', displayName: 'Claude Sonnet 4.5 (Thinking)' },
|
||||
{ name: 'gpt-oss-120b', displayName: 'GPT-OSS 120B (Medium)' },
|
||||
];
|
||||
|
||||
/** Model tier for visual grouping */
|
||||
export type ModelTier = 'primary' | 'gemini-3' | 'gemini-2' | 'other';
|
||||
|
||||
/** Model with tier info for grouped display */
|
||||
export interface TieredModel {
|
||||
name: string;
|
||||
displayName: string;
|
||||
percentage: number;
|
||||
tier: ModelTier;
|
||||
exhausted?: boolean;
|
||||
}
|
||||
|
||||
/** Get tier label for display */
|
||||
export function getTierLabel(tier: ModelTier): string {
|
||||
switch (tier) {
|
||||
case 'primary':
|
||||
return 'Claude & GPT';
|
||||
case 'gemini-3':
|
||||
return 'Gemini 3';
|
||||
case 'gemini-2':
|
||||
return 'Gemini 2.5';
|
||||
case 'other':
|
||||
return 'Other';
|
||||
}
|
||||
}
|
||||
|
||||
/** Determine tier for a model */
|
||||
function getModelTier(name: string): ModelTier {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.includes('claude') || lower.includes('gpt')) return 'primary';
|
||||
if (lower.includes('gemini 3') || lower.includes('gemini-3')) return 'gemini-3';
|
||||
if (lower.includes('gemini 2') || lower.includes('gemini-2')) return 'gemini-2';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert models to tiered format with exhausted primary models injected.
|
||||
* Groups models by tier for visual display in tooltip.
|
||||
*/
|
||||
export function getModelsWithTiers<
|
||||
T extends { name: string; displayName?: string; percentage: number },
|
||||
>(models: T[]): TieredModel[] {
|
||||
if (models.length === 0) return [];
|
||||
|
||||
const primaryModels = filterPrimaryModels(models);
|
||||
const result: TieredModel[] = [];
|
||||
|
||||
// If primary models exhausted, add known ones with 0%
|
||||
if (primaryModels.length === 0) {
|
||||
for (const known of KNOWN_PRIMARY_MODELS) {
|
||||
result.push({
|
||||
name: known.name,
|
||||
displayName: known.displayName,
|
||||
percentage: 0,
|
||||
tier: 'primary',
|
||||
exhausted: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add all models with tier info
|
||||
for (const m of models) {
|
||||
const displayName = m.displayName || m.name;
|
||||
const tier = getModelTier(displayName);
|
||||
result.push({
|
||||
name: m.name,
|
||||
displayName,
|
||||
percentage: m.percentage,
|
||||
tier,
|
||||
// Mark primary models at 0% as exhausted for red styling
|
||||
exhausted: tier === 'primary' && m.percentage === 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by tier priority, then alphabetically within tier
|
||||
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
|
||||
return result.sort((a, b) => {
|
||||
const tierDiff = tierOrder.indexOf(a.tier) - tierOrder.indexOf(b.tier);
|
||||
if (tierDiff !== 0) return tierDiff;
|
||||
return a.displayName.localeCompare(b.displayName);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Group tiered models by tier for sectioned display
|
||||
*/
|
||||
export function groupModelsByTier(models: TieredModel[]): Map<ModelTier, TieredModel[]> {
|
||||
const groups = new Map<ModelTier, TieredModel[]>();
|
||||
for (const m of models) {
|
||||
const existing = groups.get(m.tier) || [];
|
||||
existing.push(m);
|
||||
groups.set(m.tier, existing);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
@@ -24,6 +24,9 @@ import {
|
||||
useRemoveAccount,
|
||||
usePauseAccount,
|
||||
useResumeAccount,
|
||||
useSoloAccount,
|
||||
useBulkPauseAccounts,
|
||||
useBulkResumeAccounts,
|
||||
useDeleteVariant,
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import type { AuthStatus, Variant } from '@/lib/api-client';
|
||||
@@ -185,10 +188,19 @@ export function CliproxyPage() {
|
||||
const removeMutation = useRemoveAccount();
|
||||
const pauseMutation = usePauseAccount();
|
||||
const resumeMutation = useResumeAccount();
|
||||
const soloMutation = useSoloAccount();
|
||||
const bulkPauseMutation = useBulkPauseAccounts();
|
||||
const bulkResumeMutation = useBulkResumeAccounts();
|
||||
const deleteMutation = useDeleteVariant();
|
||||
|
||||
// Selection state: either a provider or a variant
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
||||
// Initialize from localStorage if available
|
||||
const [selectedProvider, setSelectedProviderState] = useState<string | null>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('cliproxy-selected-provider');
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const [selectedVariant, setSelectedVariant] = useState<string | null>(null);
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [addAccountProvider, setAddAccountProvider] = useState<{
|
||||
@@ -201,14 +213,29 @@ export function CliproxyPage() {
|
||||
const isRemoteMode = authData?.source === 'remote';
|
||||
const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]);
|
||||
|
||||
// Auto-select first provider if nothing selected
|
||||
// Wrapper to persist provider selection to localStorage
|
||||
const setSelectedProvider = (provider: string | null) => {
|
||||
setSelectedProviderState(provider);
|
||||
if (provider) {
|
||||
localStorage.setItem('cliproxy-selected-provider', provider);
|
||||
}
|
||||
};
|
||||
|
||||
// Effective provider: prefer saved > first with accounts > first
|
||||
const effectiveProvider = useMemo(() => {
|
||||
// If a variant is selected, no provider is effective
|
||||
if (selectedVariant) return null;
|
||||
|
||||
// If saved/selected provider is valid, use it
|
||||
if (selectedProvider && providers.some((p) => p.provider === selectedProvider)) {
|
||||
return selectedProvider;
|
||||
}
|
||||
return providers.length > 0 ? providers[0].provider : null;
|
||||
|
||||
// Auto-select: prefer first provider with accounts (better UX)
|
||||
if (providers.length > 0) {
|
||||
const providerWithAccounts = providers.find((p) => (p.accounts?.length || 0) > 0);
|
||||
return providerWithAccounts?.provider || providers[0]?.provider || null;
|
||||
}
|
||||
return null;
|
||||
}, [selectedProvider, selectedVariant, providers]);
|
||||
|
||||
const selectedStatus = providers.find((p) => p.provider === effectiveProvider);
|
||||
@@ -232,6 +259,21 @@ export function CliproxyPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSoloMode = (provider: string, accountId: string) => {
|
||||
if (soloMutation.isPending) return;
|
||||
soloMutation.mutate({ provider, accountId });
|
||||
};
|
||||
|
||||
const handleBulkPause = (provider: string, accountIds: string[]) => {
|
||||
if (bulkPauseMutation.isPending) return;
|
||||
bulkPauseMutation.mutate({ provider, accountIds });
|
||||
};
|
||||
|
||||
const handleBulkResume = (provider: string, accountIds: string[]) => {
|
||||
if (bulkResumeMutation.isPending) return;
|
||||
bulkResumeMutation.mutate({ provider, accountIds });
|
||||
};
|
||||
|
||||
const handleSelectProvider = (provider: string) => {
|
||||
setSelectedProvider(provider);
|
||||
setSelectedVariant(null);
|
||||
@@ -380,8 +422,16 @@ export function CliproxyPage() {
|
||||
onPauseToggle={(accountId, paused) =>
|
||||
handlePauseToggle(selectedVariantData.provider, accountId, paused)
|
||||
}
|
||||
onSoloMode={(accountId) => handleSoloMode(selectedVariantData.provider, accountId)}
|
||||
onBulkPause={(accountIds) => handleBulkPause(selectedVariantData.provider, accountIds)}
|
||||
onBulkResume={(accountIds) =>
|
||||
handleBulkResume(selectedVariantData.provider, accountIds)
|
||||
}
|
||||
isRemovingAccount={removeMutation.isPending}
|
||||
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
|
||||
isSoloingAccount={soloMutation.isPending}
|
||||
isBulkPausing={bulkPauseMutation.isPending}
|
||||
isBulkResuming={bulkResumeMutation.isPending}
|
||||
/>
|
||||
) : selectedStatus ? (
|
||||
<ProviderEditor
|
||||
@@ -412,8 +462,14 @@ export function CliproxyPage() {
|
||||
onPauseToggle={(accountId, paused) =>
|
||||
handlePauseToggle(selectedStatus.provider, accountId, paused)
|
||||
}
|
||||
onSoloMode={(accountId) => handleSoloMode(selectedStatus.provider, accountId)}
|
||||
onBulkPause={(accountIds) => handleBulkPause(selectedStatus.provider, accountIds)}
|
||||
onBulkResume={(accountIds) => handleBulkResume(selectedStatus.provider, accountIds)}
|
||||
isRemovingAccount={removeMutation.isPending}
|
||||
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
|
||||
isSoloingAccount={soloMutation.isPending}
|
||||
isBulkPausing={bulkPauseMutation.isPending}
|
||||
isBulkResuming={bulkResumeMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<EmptyProviderState onSetup={() => setWizardOpen(true)} />
|
||||
|
||||
Reference in new issue
Block a user