mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 14:15:56 +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:
1 parent
1427d36f86
commit
87226e05c4
5 files changed
+213
-43
No files matched your search
@@ -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 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 { cn, STATUS_COLORS } from '@/lib/utils';
|
||||||
import { PROVIDER_COLORS } from '@/lib/provider-config';
|
import { PROVIDER_COLORS } from '@/lib/provider-config';
|
||||||
import { ProviderIcon } from '@/components/shared/provider-icon';
|
import { ProviderIcon } from '@/components/shared/provider-icon';
|
||||||
@@ -11,6 +13,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
|||||||
import type { ProviderStats } from '../types';
|
import type { ProviderStats } from '../types';
|
||||||
import { getSuccessRate, cleanEmail } from '../utils';
|
import { getSuccessRate, cleanEmail } from '../utils';
|
||||||
import { InlineStatsBadge } from './inline-stats-badge';
|
import { InlineStatsBadge } from './inline-stats-badge';
|
||||||
|
import { ExpandableAccountList } from './expandable-account-list';
|
||||||
|
|
||||||
interface ProviderCardProps {
|
interface ProviderCardProps {
|
||||||
stats: ProviderStats;
|
stats: ProviderStats;
|
||||||
@@ -19,6 +22,10 @@ interface ProviderCardProps {
|
|||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
onMouseEnter: () => void;
|
onMouseEnter: () => void;
|
||||||
onMouseLeave: () => void;
|
onMouseLeave: () => void;
|
||||||
|
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||||
|
onSoloMode?: (accountId: string) => void;
|
||||||
|
isPausingAccount?: boolean;
|
||||||
|
isSoloingAccount?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderCard({
|
export function ProviderCard({
|
||||||
@@ -28,15 +35,32 @@ export function ProviderCard({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onMouseEnter,
|
onMouseEnter,
|
||||||
onMouseLeave,
|
onMouseLeave,
|
||||||
|
onPauseToggle,
|
||||||
|
onSoloMode,
|
||||||
|
isPausingAccount,
|
||||||
|
isSoloingAccount,
|
||||||
}: ProviderCardProps) {
|
}: ProviderCardProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const successRate = getSuccessRate(stats.successCount, stats.failureCount);
|
const successRate = getSuccessRate(stats.successCount, stats.failureCount);
|
||||||
const providerColor = PROVIDER_COLORS[stats.provider.toLowerCase()] || '#6b7280';
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onSelect}
|
onClick={handleClick}
|
||||||
onMouseEnter={onMouseEnter}
|
onMouseEnter={onMouseEnter}
|
||||||
onMouseLeave={onMouseLeave}
|
onMouseLeave={onMouseLeave}
|
||||||
|
aria-expanded={isExpandable ? isExpanded : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative rounded-xl p-4 text-left transition-all duration-300',
|
'group relative rounded-xl p-4 text-left transition-all duration-300',
|
||||||
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
|
'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' : ''}
|
{stats.accountCount} account{stats.accountCount !== 1 ? 's' : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight
|
{isExpandable ? (
|
||||||
className={cn(
|
isExpanded ? (
|
||||||
'w-4 h-4 ml-auto text-muted-foreground transition-all',
|
<ChevronDown className="w-4 h-4 ml-auto text-muted-foreground" />
|
||||||
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'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -104,40 +141,61 @@ export function ProviderCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Account color dots with warning for agy accounts missing projectId */}
|
{/* Account color dots with warning for agy accounts missing projectId */}
|
||||||
<div className="flex gap-1 mt-3 items-center">
|
{!isExpanded && (
|
||||||
{stats.accounts.slice(0, 5).map((acc) => {
|
<div className="flex gap-1 mt-3 items-center">
|
||||||
const isMissingProjectId = stats.provider === 'agy' && !acc.projectId;
|
{stats.accounts.slice(0, 5).map((acc) => {
|
||||||
return (
|
const isMissingProjectId = stats.provider === 'agy' && !acc.projectId;
|
||||||
<div key={acc.id} className="relative">
|
return (
|
||||||
<div
|
<div key={acc.id} className="relative">
|
||||||
className="w-2 h-2 rounded-full"
|
<div
|
||||||
style={{ backgroundColor: acc.color }}
|
className={cn('w-2 h-2 rounded-full', acc.paused && 'opacity-50')}
|
||||||
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
|
style={{ backgroundColor: acc.color }}
|
||||||
/>
|
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
|
||||||
{isMissingProjectId && (
|
/>
|
||||||
<TooltipProvider>
|
{isMissingProjectId && (
|
||||||
<Tooltip>
|
<TooltipProvider>
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<AlertTriangle
|
<TooltipTrigger asChild>
|
||||||
className="absolute -top-1 -right-1 w-2.5 h-2.5 text-amber-500"
|
<AlertTriangle
|
||||||
aria-label="Missing Project ID"
|
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">
|
</TooltipTrigger>
|
||||||
Missing Project ID - re-add account to fix
|
<TooltipContent side="top" className="text-xs">
|
||||||
</TooltipContent>
|
Missing Project ID - re-add account to fix
|
||||||
</Tooltip>
|
</TooltipContent>
|
||||||
</TooltipProvider>
|
</Tooltip>
|
||||||
)}
|
</TooltipProvider>
|
||||||
</div>
|
)}
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
{stats.accounts.length > 5 && (
|
})}
|
||||||
<span className="text-[10px] text-muted-foreground ml-1">
|
{stats.accounts.length > 5 && (
|
||||||
+{stats.accounts.length - 5}
|
<span className="text-[10px] text-muted-foreground ml-1">
|
||||||
</span>
|
+{stats.accounts.length - 5}
|
||||||
)}
|
</span>
|
||||||
</div>
|
)}
|
||||||
|
</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>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -100,6 +100,7 @@ export function useAuthMonitorData(): AuthMonitorData {
|
|||||||
lastUsedAt: realStats?.lastUsedAt ?? account.lastUsedAt,
|
lastUsedAt: realStats?.lastUsedAt ?? account.lastUsedAt,
|
||||||
color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length],
|
color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length],
|
||||||
projectId: account.projectId,
|
projectId: account.projectId,
|
||||||
|
paused: account.paused,
|
||||||
};
|
};
|
||||||
accountsList.push(row);
|
accountsList.push(row);
|
||||||
providerData.accounts.push(row);
|
providerData.accounts.push(row);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { STATUS_COLORS } from '@/lib/utils';
|
|||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { AccountFlowViz } from '@/components/account-flow-viz';
|
import { AccountFlowViz } from '@/components/account-flow-viz';
|
||||||
import { usePrivacy } from '@/contexts/privacy-context';
|
import { usePrivacy } from '@/contexts/privacy-context';
|
||||||
|
import { usePauseAccount, useResumeAccount, useSoloAccount } from '@/hooks/use-cliproxy';
|
||||||
import { Activity, CheckCircle2, XCircle, Radio } from 'lucide-react';
|
import { Activity, CheckCircle2, XCircle, Radio } from 'lucide-react';
|
||||||
|
|
||||||
import { useAuthMonitorData } from './hooks';
|
import { useAuthMonitorData } from './hooks';
|
||||||
@@ -33,11 +34,30 @@ export function AuthMonitor() {
|
|||||||
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
||||||
const [hoveredProvider, setHoveredProvider] = 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
|
// Get selected provider data for detail view
|
||||||
const selectedProviderData = selectedProvider
|
const selectedProviderData = selectedProvider
|
||||||
? providerStats.find((ps) => ps.provider === selectedProvider)
|
? providerStats.find((ps) => ps.provider === selectedProvider)
|
||||||
: null;
|
: 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) {
|
if (isLoading) {
|
||||||
return (
|
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">
|
<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)}
|
onSelect={() => setSelectedProvider(ps.provider)}
|
||||||
onMouseEnter={() => setHoveredProvider(ps.provider)}
|
onMouseEnter={() => setHoveredProvider(ps.provider)}
|
||||||
onMouseLeave={() => setHoveredProvider(null)}
|
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>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface AccountRow {
|
|||||||
color: string;
|
color: string;
|
||||||
/** GCP Project ID (Antigravity only) - read-only */
|
/** GCP Project ID (Antigravity only) - read-only */
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
/** Whether account is paused (skipped in quota rotation) */
|
||||||
|
paused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderStats {
|
export interface ProviderStats {
|
||||||
|
|||||||
Reference in new issue
Block a user