mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
feat(ui): add expandable provider cards with account controls
- add ExpandableAccountList for inline account management - add expand/collapse to ProviderCard with smooth transitions - wire pause/resume/solo mutations to auth-monitor index - add paused field to AccountRow type for UI display
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Expandable Account List - Account rows with toggle and solo controls
|
||||
* Used in ProviderCard when expanded to show individual account controls
|
||||
*/
|
||||
|
||||
import { Radio } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cleanEmail } from '../utils';
|
||||
import type { AccountRow } from '../types';
|
||||
|
||||
interface ExpandableAccountListProps {
|
||||
accounts: AccountRow[];
|
||||
privacyMode: boolean;
|
||||
onPauseToggle: (accountId: string, paused: boolean) => void;
|
||||
onSoloMode: (accountId: string) => void;
|
||||
isPausingAccount?: boolean;
|
||||
isSoloingAccount?: boolean;
|
||||
}
|
||||
|
||||
export function ExpandableAccountList({
|
||||
accounts,
|
||||
privacyMode,
|
||||
onPauseToggle,
|
||||
onSoloMode,
|
||||
isPausingAccount,
|
||||
isSoloingAccount,
|
||||
}: ExpandableAccountListProps) {
|
||||
return (
|
||||
<div className="space-y-1 pt-3 border-t border-border/50">
|
||||
{accounts.map((acc) => (
|
||||
<div
|
||||
key={acc.id}
|
||||
className={cn(
|
||||
'flex items-center justify-between py-1.5 px-2 rounded',
|
||||
acc.paused && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: acc.color }} />
|
||||
<span className="text-xs truncate max-w-[140px]" title={acc.email}>
|
||||
{privacyMode ? '••••••' : cleanEmail(acc.email)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSoloMode(acc.id);
|
||||
}}
|
||||
disabled={isSoloingAccount || acc.paused}
|
||||
>
|
||||
<Radio className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
Solo mode - activate this, pause others
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Switch
|
||||
checked={!acc.paused}
|
||||
onCheckedChange={(checked) => {
|
||||
onPauseToggle(acc.id, !checked);
|
||||
}}
|
||||
disabled={isPausingAccount}
|
||||
className="scale-75"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* ProviderCard - Provider status card with account color dots and stats
|
||||
* ProviderCard - Provider status card with expandable account controls
|
||||
* Click to expand and show individual account toggle/solo buttons
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { ChevronRight, AlertTriangle } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ChevronRight, ChevronDown, AlertTriangle } from 'lucide-react';
|
||||
import { cn, STATUS_COLORS } from '@/lib/utils';
|
||||
import { PROVIDER_COLORS } from '@/lib/provider-config';
|
||||
import { ProviderIcon } from '@/components/shared/provider-icon';
|
||||
@@ -11,6 +13,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
import type { ProviderStats } from '../types';
|
||||
import { getSuccessRate, cleanEmail } from '../utils';
|
||||
import { InlineStatsBadge } from './inline-stats-badge';
|
||||
import { ExpandableAccountList } from './expandable-account-list';
|
||||
|
||||
interface ProviderCardProps {
|
||||
stats: ProviderStats;
|
||||
@@ -19,6 +22,10 @@ interface ProviderCardProps {
|
||||
onSelect: () => void;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
onSoloMode?: (accountId: string) => void;
|
||||
isPausingAccount?: boolean;
|
||||
isSoloingAccount?: boolean;
|
||||
}
|
||||
|
||||
export function ProviderCard({
|
||||
@@ -28,15 +35,32 @@ export function ProviderCard({
|
||||
onSelect,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onPauseToggle,
|
||||
onSoloMode,
|
||||
isPausingAccount,
|
||||
isSoloingAccount,
|
||||
}: ProviderCardProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const successRate = getSuccessRate(stats.successCount, stats.failureCount);
|
||||
const providerColor = PROVIDER_COLORS[stats.provider.toLowerCase()] || '#6b7280';
|
||||
|
||||
// Only expandable if account control callbacks are provided
|
||||
const isExpandable = !!(onPauseToggle && onSoloMode);
|
||||
|
||||
const handleClick = () => {
|
||||
if (isExpandable) {
|
||||
setIsExpanded(!isExpanded);
|
||||
} else {
|
||||
onSelect();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
aria-expanded={isExpandable ? isExpanded : undefined}
|
||||
className={cn(
|
||||
'group relative rounded-xl p-4 text-left transition-all duration-300',
|
||||
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
|
||||
@@ -61,12 +85,25 @@ export function ProviderCard({
|
||||
{stats.accountCount} account{stats.accountCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'w-4 h-4 ml-auto text-muted-foreground transition-all',
|
||||
isHovered ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-2'
|
||||
)}
|
||||
/>
|
||||
{isExpandable ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 ml-auto text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'w-4 h-4 ml-auto text-muted-foreground transition-all',
|
||||
isHovered ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-2'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'w-4 h-4 ml-auto text-muted-foreground transition-all',
|
||||
isHovered ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-2'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -104,40 +141,61 @@ export function ProviderCard({
|
||||
</div>
|
||||
|
||||
{/* Account color dots with warning for agy accounts missing projectId */}
|
||||
<div className="flex gap-1 mt-3 items-center">
|
||||
{stats.accounts.slice(0, 5).map((acc) => {
|
||||
const isMissingProjectId = stats.provider === 'agy' && !acc.projectId;
|
||||
return (
|
||||
<div key={acc.id} className="relative">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: acc.color }}
|
||||
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
|
||||
/>
|
||||
{isMissingProjectId && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertTriangle
|
||||
className="absolute -top-1 -right-1 w-2.5 h-2.5 text-amber-500"
|
||||
aria-label="Missing Project ID"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
Missing Project ID - re-add account to fix
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{stats.accounts.length > 5 && (
|
||||
<span className="text-[10px] text-muted-foreground ml-1">
|
||||
+{stats.accounts.length - 5}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!isExpanded && (
|
||||
<div className="flex gap-1 mt-3 items-center">
|
||||
{stats.accounts.slice(0, 5).map((acc) => {
|
||||
const isMissingProjectId = stats.provider === 'agy' && !acc.projectId;
|
||||
return (
|
||||
<div key={acc.id} className="relative">
|
||||
<div
|
||||
className={cn('w-2 h-2 rounded-full', acc.paused && 'opacity-50')}
|
||||
style={{ backgroundColor: acc.color }}
|
||||
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
|
||||
/>
|
||||
{isMissingProjectId && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertTriangle
|
||||
className="absolute -top-1 -right-1 w-2.5 h-2.5 text-amber-500"
|
||||
aria-label="Missing Project ID"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
Missing Project ID - re-add account to fix
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{stats.accounts.length > 5 && (
|
||||
<span className="text-[10px] text-muted-foreground ml-1">
|
||||
+{stats.accounts.length - 5}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expandable account list */}
|
||||
{isExpandable && (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden transition-all duration-200',
|
||||
isExpanded ? 'max-h-96' : 'max-h-0'
|
||||
)}
|
||||
>
|
||||
<ExpandableAccountList
|
||||
accounts={stats.accounts}
|
||||
privacyMode={privacyMode}
|
||||
onPauseToggle={onPauseToggle}
|
||||
onSoloMode={onSoloMode}
|
||||
isPausingAccount={isPausingAccount}
|
||||
isSoloingAccount={isSoloingAccount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,6 +9,7 @@ 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, useSoloAccount } from '@/hooks/use-cliproxy';
|
||||
import { Activity, CheckCircle2, XCircle, Radio } from 'lucide-react';
|
||||
|
||||
import { useAuthMonitorData } from './hooks';
|
||||
@@ -33,11 +34,30 @@ export function AuthMonitor() {
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
||||
const [hoveredProvider, setHoveredProvider] = useState<string | null>(null);
|
||||
|
||||
// Account control mutations
|
||||
const pauseMutation = usePauseAccount();
|
||||
const resumeMutation = useResumeAccount();
|
||||
const soloMutation = useSoloAccount();
|
||||
|
||||
// Get selected provider data for detail view
|
||||
const selectedProviderData = selectedProvider
|
||||
? providerStats.find((ps) => ps.provider === selectedProvider)
|
||||
: null;
|
||||
|
||||
const handlePauseToggle = (provider: string, accountId: string, paused: boolean) => {
|
||||
if (pauseMutation.isPending || resumeMutation.isPending) return;
|
||||
if (paused) {
|
||||
pauseMutation.mutate({ provider, accountId });
|
||||
} else {
|
||||
resumeMutation.mutate({ provider, accountId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSoloMode = (provider: string, accountId: string) => {
|
||||
if (soloMutation.isPending) return;
|
||||
soloMutation.mutate({ provider, 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">
|
||||
@@ -137,6 +157,12 @@ export function AuthMonitor() {
|
||||
onSelect={() => setSelectedProvider(ps.provider)}
|
||||
onMouseEnter={() => setHoveredProvider(ps.provider)}
|
||||
onMouseLeave={() => setHoveredProvider(null)}
|
||||
onPauseToggle={(accountId, paused) =>
|
||||
handlePauseToggle(ps.provider, accountId, paused)
|
||||
}
|
||||
onSoloMode={(accountId) => handleSoloMode(ps.provider, accountId)}
|
||||
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
|
||||
isSoloingAccount={soloMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user