diff --git a/ui/src/components/accounts-table.tsx b/ui/src/components/accounts-table.tsx deleted file mode 100644 index 65865f03..00000000 --- a/ui/src/components/accounts-table.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Accounts Table Component - * Phase 03: REST API Routes & CRUD - */ - -import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { Button } from '@/components/ui/button'; -import { Check } from 'lucide-react'; -import { useSetDefaultAccount } from '@/hooks/use-accounts'; -import type { Account } from '@/lib/api-client'; - -interface AccountsTableProps { - data: Account[]; - defaultAccount: string | null; -} - -export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { - const setDefaultMutation = useSetDefaultAccount(); - - const columns: ColumnDef[] = [ - { - accessorKey: 'name', - header: 'Name', - size: 200, - cell: ({ row }) => ( -
- {row.original.name} - {row.original.name === defaultAccount && ( - - default - - )} -
- ), - }, - { - accessorKey: 'type', - header: 'Type', - size: 100, - cell: ({ row }) => ( - {row.original.type || 'oauth'} - ), - }, - { - accessorKey: 'created', - header: 'Created', - size: 150, - cell: ({ row }) => { - const date = new Date(row.original.created); - return {date.toLocaleDateString()}; - }, - }, - { - accessorKey: 'last_used', - header: 'Last Used', - size: 150, - cell: ({ row }) => { - if (!row.original.last_used) return -; - const date = new Date(row.original.last_used); - return {date.toLocaleDateString()}; - }, - }, - { - id: 'actions', - header: 'Actions', - size: 100, - cell: ({ row }) => { - const isDefault = row.original.name === defaultAccount; - return ( - - ); - }, - }, - ]; - - // eslint-disable-next-line react-hooks/incompatible-library - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - }); - - if (data.length === 0) { - return ( -
- No accounts found. Use ccs login to - add accounts. -
- ); - } - - return ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const widthClass = - { - name: 'w-[200px]', - type: 'w-[100px]', - created: 'w-[150px]', - last_used: 'w-[150px]', - actions: 'w-[100px]', - }[header.id] || 'w-auto'; - - return ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ); - })} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} - -
-
- ); -} diff --git a/ui/src/components/add-account-dialog.tsx b/ui/src/components/add-account-dialog.tsx deleted file mode 100644 index fa01fa4e..00000000 --- a/ui/src/components/add-account-dialog.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Add Account Dialog Component - * Triggers OAuth flow server-side to add another account to a provider - * Applies default preset when adding first account - */ - -import { useState } from 'react'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Loader2, ExternalLink, User } from 'lucide-react'; -import { useStartAuth } from '@/hooks/use-cliproxy'; -import { applyDefaultPreset } from '@/lib/preset-utils'; -import { toast } from 'sonner'; - -interface AddAccountDialogProps { - open: boolean; - onClose: () => void; - provider: string; - displayName: string; - /** Whether this is the first account being added (triggers preset application) */ - isFirstAccount?: boolean; -} - -export function AddAccountDialog({ - open, - onClose, - provider, - displayName, - isFirstAccount = false, -}: AddAccountDialogProps) { - const [nickname, setNickname] = useState(''); - const startAuthMutation = useStartAuth(); - - const handleStartAuth = () => { - startAuthMutation.mutate( - { provider, nickname: nickname.trim() || undefined }, - { - onSuccess: async () => { - // Apply default preset if this is the first account - if (isFirstAccount) { - const result = await applyDefaultPreset(provider); - if (result.success && result.presetName) { - toast.success(`Applied "${result.presetName}" preset`); - } else if (!result.success) { - toast.warning('Account added, but failed to apply default preset'); - } - } - setNickname(''); - onClose(); - }, - } - ); - }; - - const handleOpenChange = (isOpen: boolean) => { - if (!isOpen && !startAuthMutation.isPending) { - setNickname(''); - onClose(); - } - }; - - return ( - - - - Add {displayName} Account - - Click the button below to authenticate a new account. A browser window will open for - OAuth. - - - -
-
- -
- - setNickname(e.target.value)} - placeholder="e.g., work, personal" - disabled={startAuthMutation.isPending} - className="flex-1" - /> -
-

- A friendly name to identify this account. Auto-generated from email if left empty. -

-
- -
- - -
- - {startAuthMutation.isPending && ( -

- Complete the OAuth flow in your browser... -

- )} -
-
-
- ); -} diff --git a/ui/src/components/app-sidebar.tsx b/ui/src/components/app-sidebar.tsx deleted file mode 100644 index ccf4ced7..00000000 --- a/ui/src/components/app-sidebar.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { Link, useLocation } from 'react-router-dom'; -import { - Home, - Key, - Zap, - Users, - Settings, - Activity, - FolderOpen, - ChevronRight, - BarChart3, - Gauge, - Github, -} from 'lucide-react'; -import { - Sidebar, - SidebarContent, - SidebarMenu, - SidebarMenuItem, - SidebarMenuButton, - SidebarMenuSub, - SidebarMenuSubItem, - SidebarMenuSubButton, - SidebarGroup, - SidebarGroupLabel, - SidebarHeader, - SidebarFooter, - SidebarTrigger, - SidebarGroupContent, -} from '@/components/ui/sidebar'; -import { CcsLogo } from '@/components/ccs-logo'; -import { useSidebar } from '@/hooks/use-sidebar'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; - -// Define navigation groups -const navGroups = [ - { - title: 'General', - items: [ - { path: '/', icon: Home, label: 'Home' }, - { path: '/analytics', icon: BarChart3, label: 'Analytics' }, - ], - }, - { - title: 'Identity & Access', - items: [ - { path: '/api', icon: Key, label: 'API Profiles' }, - { - path: '/cliproxy', - icon: Zap, - label: 'CLIProxy', - isCollapsible: true, - children: [ - { path: '/cliproxy', label: 'Overview' }, - { path: '/cliproxy/control-panel', icon: Gauge, label: 'Control Panel' }, - ], - }, - { path: '/copilot', icon: Github, label: 'GitHub Copilot' }, - { - path: '/accounts', - icon: Users, - label: 'Accounts', - isCollapsible: true, - children: [ - { path: '/accounts', label: 'All Accounts' }, - { path: '/shared', icon: FolderOpen, label: 'Shared Data' }, - ], - }, - ], - }, - { - title: 'System', - items: [ - { path: '/health', icon: Activity, label: 'Health' }, - { path: '/settings', icon: Settings, label: 'Settings' }, - ], - }, -]; - -export function AppSidebar() { - const location = useLocation(); - const { state } = useSidebar(); - - // Helper to check if a route is active (exact match) - const isRouteActive = (path: string) => location.pathname === path; - - // Helper to check if a group/parent should be open based on active child - // Also handles sub-routes (e.g., /cliproxy/control-panel matches /cliproxy) - const isParentActive = (children: { path: string }[]) => { - return children.some( - (child) => isRouteActive(child.path) || location.pathname.startsWith(child.path + '/') - ); - }; - - return ( - - - - - - - {navGroups.map((group, index) => ( - - {group.title && {group.title}} - - - {group.items.map((item) => ( - - {item.isCollapsible && item.children ? ( - - - - - {item.icon && } - - {item.label} - - - - - - - {item.children.map((child) => ( - - - - {child.label} - - - - ))} - - - - - ) : ( - - - {item.icon && } - {item.label} - - - )} - - ))} - - - - ))} - - - - - - - ); -} diff --git a/ui/src/components/auth-monitor.tsx b/ui/src/components/auth-monitor.tsx deleted file mode 100644 index f6bfccc1..00000000 --- a/ui/src/components/auth-monitor.tsx +++ /dev/null @@ -1,465 +0,0 @@ -/** - * Auth Monitor Component with Account Flow Visualization - * Shows request flow from accounts to providers using custom SVG bezier curves - * Uses glass panel aesthetic with hover interactions and glow effects - */ - -import { useState, useMemo, useEffect } from 'react'; -import { useCliproxyAuth } from '@/hooks/use-cliproxy'; -import { useCliproxyStats, type AccountUsageStats } from '@/hooks/use-cliproxy-stats'; -import { cn, STATUS_COLORS } from '@/lib/utils'; -import { getProviderDisplayName, PROVIDER_COLORS } from '@/lib/provider-config'; -import { Skeleton } from '@/components/ui/skeleton'; -import { ProviderIcon } from '@/components/provider-icon'; -import { AccountFlowViz } from '@/components/account-flow-viz'; -import { usePrivacy } from '@/contexts/privacy-context'; -import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; -import { Activity, CheckCircle2, XCircle, ChevronRight, Radio } from 'lucide-react'; - -interface AccountRow { - id: string; - email: string; - provider: string; - displayName: string; - isDefault: boolean; - successCount: number; - failureCount: number; - lastUsedAt?: string; - color: string; -} - -interface ProviderStats { - provider: string; - displayName: string; - totalRequests: number; - successCount: number; - failureCount: number; - accountCount: number; - accounts: AccountRow[]; -} - -function getSuccessRate(success: number, failure: number): number { - const total = success + failure; - if (total === 0) return 100; - return Math.round((success / total) * 100); -} - -/** Strip common email domains for cleaner display */ -function cleanEmail(email: string): string { - return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, ''); -} - -// Vibrant colors for account segments - darker for light theme contrast -const ACCOUNT_COLORS = [ - '#1e6091', // Deep Cerulean (was #277da1) - '#2d8a6e', // Deep Seaweed (was #43aa8b) - '#d4a012', // Dark Tuscan (was #f9c74f) - '#c92a2d', // Deep Strawberry (was #f94144) - '#c45a1a', // Deep Pumpkin (was #f3722c) - '#6b9c4d', // Dark Willow (was #90be6d) - '#3d5a73', // Deep Blue Slate (was #577590) - '#cc7614', // Dark Carrot (was #f8961e) - '#3a7371', // Deep Cyan (was #4d908e) - '#7c5fc4', // Deep Purple (was #a78bfa) -]; - -/** Enhanced live pulse indicator with multi-ring animation */ -function LivePulse() { - return ( -
- {/* Outer ping ring */} -
- {/* Middle pulse ring */} -
- {/* Inner solid dot */} -
-
- ); -} - -/** Inline success/failure badge for provider cards */ -function InlineStatsBadge({ success, failure }: { success: number; failure: number }) { - if (success === 0 && failure === 0) { - return no activity; - } - - return ( -
-
- - - {success.toLocaleString()} - -
- {failure > 0 && ( -
- - - {failure.toLocaleString()} - -
- )} -
- ); -} - -export function AuthMonitor() { - const { data, isLoading, error } = useCliproxyAuth(); - const { data: statsData, isLoading: statsLoading, dataUpdatedAt } = useCliproxyStats(); - const { privacyMode } = usePrivacy(); - const [selectedProvider, setSelectedProvider] = useState(null); - const [hoveredProvider, setHoveredProvider] = useState(null); - const [timeSinceUpdate, setTimeSinceUpdate] = useState(''); - - // Live countdown showing time since last data update - useEffect(() => { - if (!dataUpdatedAt) return; - const updateTime = () => { - const diff = Math.floor((Date.now() - dataUpdatedAt) / 1000); - if (diff < 60) { - setTimeSinceUpdate(`${diff}s ago`); - } else { - setTimeSinceUpdate(`${Math.floor(diff / 60)}m ago`); - } - }; - updateTime(); - const interval = setInterval(updateTime, 1000); - return () => clearInterval(interval); - }, [dataUpdatedAt]); - - // Build a map of account email -> usage stats from CLIProxy - const accountStatsMap = useMemo(() => { - if (!statsData?.accountStats) return new Map(); - return new Map(Object.entries(statsData.accountStats)); - }, [statsData?.accountStats]); - - // Transform auth status data into account rows - const { accounts, totalSuccess, totalFailure, totalRequests, providerStats } = useMemo(() => { - if (!data?.authStatus) { - return { - accounts: [], - totalSuccess: 0, - totalFailure: 0, - totalRequests: 0, - providerStats: [], - }; - } - - const accountsList: AccountRow[] = []; - const providerMap = new Map< - string, - { success: number; failure: number; accounts: AccountRow[] } - >(); - let tSuccess = 0; - let tFailure = 0; - let colorIndex = 0; - - data.authStatus.forEach((status: AuthStatus) => { - const providerKey = status.provider; - if (!providerMap.has(providerKey)) { - providerMap.set(providerKey, { success: 0, failure: 0, accounts: [] }); - } - const providerData = providerMap.get(providerKey); - if (!providerData) return; - - status.accounts?.forEach((account: OAuthAccount) => { - // Get real stats from CLIProxy - try email first, then id - const accountEmail = account.email || account.id; - const realStats = accountStatsMap.get(accountEmail); - const success = realStats?.successCount ?? 0; - const failure = realStats?.failureCount ?? 0; - tSuccess += success; - tFailure += failure; - providerData.success += success; - providerData.failure += failure; - - const row: AccountRow = { - id: account.id, - email: account.email || account.id, - provider: status.provider, - displayName: status.displayName, - isDefault: account.isDefault, - successCount: success, - failureCount: failure, - lastUsedAt: realStats?.lastUsedAt ?? account.lastUsedAt, - color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length], - }; - accountsList.push(row); - providerData.accounts.push(row); - colorIndex++; - }); - }); - - // Build provider stats array - const providerStatsArr: ProviderStats[] = []; - providerMap.forEach((pData, provider) => { - if (pData.accounts.length === 0) return; - providerStatsArr.push({ - provider, - displayName: getProviderDisplayName(provider), - totalRequests: pData.success + pData.failure, - successCount: pData.success, - failureCount: pData.failure, - accountCount: pData.accounts.length, - accounts: pData.accounts, - }); - }); - providerStatsArr.sort((a, b) => b.totalRequests - a.totalRequests); - - return { - accounts: accountsList, - totalSuccess: tSuccess, - totalFailure: tFailure, - totalRequests: tSuccess + tFailure, - providerStats: providerStatsArr, - }; - }, [data?.authStatus, accountStatsMap]); - - const overallSuccessRate = - totalRequests > 0 ? Math.round((totalSuccess / totalRequests) * 100) : 100; - - // Get selected provider data for detail view - const selectedProviderData = selectedProvider - ? providerStats.find((ps) => ps.provider === selectedProvider) - : null; - - if (isLoading || statsLoading) { - return ( -
-
- - -
-
-
- {[1, 2, 3, 4].map((i) => ( - - ))} -
- -
-
- ); - } - - if (error || !data?.authStatus || accounts.length === 0) { - return null; - } - - return ( -
- {/* Enhanced Live Header with gradient glow */} -
-
- - LIVE - Account Monitor -
-
-
- - Updated {timeSinceUpdate || 'now'} -
- | - {accounts.length} accounts - {totalRequests.toLocaleString()} req -
-
- - {/* Summary Stats Row */} -
- } - label="Accounts" - value={accounts.length} - color="var(--accent)" - /> - } - label="Success" - value={totalSuccess.toLocaleString()} - color={STATUS_COLORS.success} - /> - } - label="Failed" - value={totalFailure.toLocaleString()} - color={totalFailure > 0 ? STATUS_COLORS.failed : undefined} - /> - } - label="Success Rate" - value={`${overallSuccessRate}%`} - color={ - overallSuccessRate === 100 - ? STATUS_COLORS.success - : overallSuccessRate >= 95 - ? STATUS_COLORS.degraded - : STATUS_COLORS.failed - } - /> -
- - {/* Flow Visualization */} -
- {selectedProviderData ? ( - // Account-level flow view - setSelectedProvider(null)} - /> - ) : ( - // Provider cards view -
-
- Request Distribution by Provider -
-
- {providerStats.map((ps) => { - const successRate = getSuccessRate(ps.successCount, ps.failureCount); - const providerColor = PROVIDER_COLORS[ps.provider.toLowerCase()] || '#6b7280'; - const isHovered = hoveredProvider === ps.provider; - - return ( - - ); - })} -
-
- )} -
-
- ); -} - -// Summary Card Component -function SummaryCard({ - icon, - label, - value, - color, -}: { - icon: React.ReactNode; - label: string; - value: string | number; - color?: string; -}) { - return ( -
-
- {icon} -
-
-
{label}
-
- {value} -
-
-
- ); -} diff --git a/ui/src/components/ccs-logo.tsx b/ui/src/components/ccs-logo.tsx deleted file mode 100644 index 58e6eda6..00000000 --- a/ui/src/components/ccs-logo.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { cn } from '@/lib/utils'; - -interface CcsLogoProps { - size?: 'sm' | 'md' | 'lg'; - className?: string; - showText?: boolean; -} - -const sizeMap = { - sm: 24, - md: 32, - lg: 48, -}; - -export function CcsLogo({ size = 'md', className, showText = true }: CcsLogoProps) { - const dimension = sizeMap[size]; - - return ( -
- CCS Logo - {showText && CCS Config} -
- ); -} diff --git a/ui/src/components/claudekit-badge.tsx b/ui/src/components/claudekit-badge.tsx deleted file mode 100644 index a8c4308b..00000000 --- a/ui/src/components/claudekit-badge.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/** - * ClaudeKit Badge Button - * - * "Powered by ClaudeKit" badge for navbar, inspired by landing page design. - * Compact version optimized for header placement. - */ - -import { cn } from '@/lib/utils'; - -const CLAUDEKIT_URL = 'https://claudekit.cc?ref=HMNKXOHN'; - -export function ClaudeKitBadge() { - return ( - - ClaudeKit - - - Powered by - - - ClaudeKit - - - - ); -} diff --git a/ui/src/components/cliproxy-dialog.tsx b/ui/src/components/cliproxy-dialog.tsx deleted file mode 100644 index f699305d..00000000 --- a/ui/src/components/cliproxy-dialog.tsx +++ /dev/null @@ -1,157 +0,0 @@ -/** - * CLIProxy Variant Dialog Component - * Phase 03: REST API Routes & CRUD - * Phase 06: Multi-Account Support - */ - -import { useForm, useWatch } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy'; -import { usePrivacy } from '@/contexts/privacy-context'; - -const providers = ['gemini', 'codex', 'agy', 'qwen', 'iflow'] as const; - -const schema = z.object({ - name: z - .string() - .min(1, 'Name is required') - .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid variant name'), - provider: z.enum(providers, { message: 'Provider is required' }), - model: z.string().optional(), - account: z.string().optional(), -}); - -type FormData = z.infer; - -interface CliproxyDialogProps { - open: boolean; - onClose: () => void; -} - -const providerOptions = [ - { value: 'gemini', label: 'Google Gemini' }, - { value: 'codex', label: 'OpenAI Codex' }, - { value: 'agy', label: 'Antigravity' }, - { value: 'qwen', label: 'Alibaba Qwen' }, - { value: 'iflow', label: 'iFlow' }, -]; - -export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { - const createMutation = useCreateVariant(); - const { data: authData } = useCliproxyAuth(); - const { privacyMode } = usePrivacy(); - - const { - register, - handleSubmit, - control, - formState: { errors }, - reset, - } = useForm({ - resolver: zodResolver(schema), - }); - - // Watch provider to show relevant accounts - const selectedProvider = useWatch({ control, name: 'provider' }); - - // Get accounts for selected provider - const providerAuth = authData?.authStatus.find((s) => s.provider === selectedProvider); - const providerAccounts = providerAuth?.accounts || []; - - const onSubmit = async (data: FormData) => { - try { - await createMutation.mutateAsync(data); - reset(); - onClose(); - } catch (error) { - console.error('Failed to create variant:', error); - } - }; - - return ( - - - - Create CLIProxy Variant - -
-
- - - {errors.name && {errors.name.message}} -
- -
- - - {errors.provider && ( - {errors.provider.message} - )} -
- - {/* Account selector - only show if provider has accounts */} - {selectedProvider && providerAccounts.length > 0 && ( -
- - - - Select which OAuth account this variant should use - -
- )} - - {/* Show message if provider selected but no accounts */} - {selectedProvider && providerAccounts.length === 0 && providerAuth && ( -
- No accounts authenticated for {providerAuth.displayName}. -
- ccs {selectedProvider} --auth -
- )} - -
- - -
- -
- - -
-
-
-
- ); -} diff --git a/ui/src/components/cliproxy-stats-overview.tsx b/ui/src/components/cliproxy-stats-overview.tsx deleted file mode 100644 index 0e871eb9..00000000 --- a/ui/src/components/cliproxy-stats-overview.tsx +++ /dev/null @@ -1,349 +0,0 @@ -/** - * CLIProxy Stats Overview Component - * - * Full-width dashboard section showing comprehensive CLIProxyAPI statistics. - * Features: - * - Status indicator with uptime - * - Request metrics with success rate visualization - * - Token usage with cost estimation - * - Model breakdown with usage distribution - * - Session history - * Respects privacy mode to blur sensitive data. - */ - -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { Skeleton } from '@/components/ui/skeleton'; -import { - Server, - Zap, - ZapOff, - Activity, - Coins, - Cpu, - CheckCircle2, - XCircle, - TrendingUp, -} from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats'; -import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; - -interface CliproxyStatsOverviewProps { - className?: string; -} - -export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps) { - const { privacyMode } = usePrivacy(); - const { data: status, isLoading: statusLoading } = useCliproxyStatus(); - const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running); - - const isLoading = statusLoading || (status?.running && statsLoading); - - // Loading state - if (isLoading) { - return ( -
-
-
- - -
- -
-
- {[1, 2, 3, 4].map((i) => ( - - ))} -
-
- ); - } - - // Offline state - if (!status?.running) { - return ( -
-
-
-

- - Session Statistics -

-

- Real-time usage metrics from CLIProxyAPI -

-
- - - Offline - -
- - - -
- -
-

No Active Session

-

- Start a CLIProxy session using{' '} - ccs gemini,{' '} - ccs codex, - or ccs agy{' '} - to view real-time statistics. -

-
-
-
- ); - } - - // Error state - if (error) { - return ( -
- - - -
-

Failed to Load Statistics

-

{error.message}

-
-
-
-
- ); - } - - // Calculate derived stats - const totalRequests = stats?.totalRequests ?? 0; - const failedRequests = stats?.quotaExceededCount ?? 0; - const successRequests = totalRequests - failedRequests; - const successRate = totalRequests > 0 ? Math.round((successRequests / totalRequests) * 100) : 100; - const totalTokens = stats?.tokens?.total ?? 0; - - // Get model breakdown sorted by usage - const models = Object.entries(stats?.requestsByModel ?? {}).sort((a, b) => b[1] - a[1]); - const maxModelRequests = models.length > 0 ? models[0][1] : 1; - - // Define color palette for models - const modelColors = [ - 'bg-blue-500', - 'bg-purple-500', - 'bg-amber-500', - 'bg-emerald-500', - 'bg-rose-500', - 'bg-cyan-500', - ]; - - return ( -
- {/* Header */} -
-
-

- - Session Statistics -

-

Real-time usage metrics from CLIProxyAPI

-
- - - Running - -
- - {/* Stats Grid */} -
- {/* Requests Card */} - - -
-
-

Total Requests

-

{formatNumber(totalRequests)}

-
- - {successRequests} success -
-
-
- -
-
-
-
- - {/* Success Rate Card */} - - -
-
-

Success Rate

-

{successRate}%

-
-
= 90 ? 'bg-green-500' : 'bg-amber-500' - )} - style={{ width: `${successRate}%` }} - /> -
-
-
= 90 - ? 'bg-green-100 dark:bg-green-900/20' - : 'bg-amber-100 dark:bg-amber-900/20' - )} - > - {successRate >= 90 ? ( - - ) : ( - - )} -
-
- - - - {/* Tokens Card */} - - -
-
-

Total Tokens

-

- {formatNumber(totalTokens)} -

-

- ~${estimateCost(totalTokens).toFixed(2)} estimated -

-
-
- -
-
-
-
- - {/* Models Card */} - - -
-
-

Models Used

-

{models.length}

-

- {models.length > 0 ? formatModelName(models[0][0]) : 'None'} -

-
-
- -
-
-
-
-
- - {/* Model Breakdown */} - {models.length > 0 && ( - - - - - Model Usage Distribution - - - -
- {models.map(([model, count], index) => { - const percentage = Math.round((count / totalRequests) * 100); - const barPercentage = Math.round((count / maxModelRequests) * 100); - const displayName = formatModelName(model); - const colorClass = modelColors[index % modelColors.length]; - - return ( -
-
-
-
- - {displayName} - -
-
- {count} requests - {percentage}% -
-
-
-
-
-
- ); - })} -
- - - )} -
- ); -} - -/** Format large numbers with K/M suffix */ -function formatNumber(num: number): string { - if (num >= 1000000) { - return `${(num / 1000000).toFixed(1)}M`; - } - if (num >= 1000) { - return `${(num / 1000).toFixed(1)}K`; - } - return num.toLocaleString(); -} - -/** Format model names for display */ -function formatModelName(model: string): string { - let name = model - .replace(/^gemini-claude-/, '') - .replace(/^gemini-/, '') - .replace(/^claude-/, '') - .replace(/^anthropic\./, '') - .replace(/-thinking$/, ' Thinking'); - - name = name - .split(/[-_]/) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); - - if (name.length > 25) { - name = name.slice(0, 23) + '...'; - } - - return name; -} - -/** Estimate cost based on token count (rough average) */ -function estimateCost(tokens: number): number { - // Average cost per 1M tokens across models (~$3 for input, ~$15 for output) - // Assuming 30% input, 70% output ratio - const avgCostPerMillion = 3 * 0.3 + 15 * 0.7; - return (tokens / 1000000) * avgCostPerMillion; -} diff --git a/ui/src/components/cliproxy-table.tsx b/ui/src/components/cliproxy-table.tsx deleted file mode 100644 index b9f7e2bb..00000000 --- a/ui/src/components/cliproxy-table.tsx +++ /dev/null @@ -1,154 +0,0 @@ -/** - * CLIProxy Variants Table Component - * Phase 03: REST API Routes & CRUD - * Phase 06: Multi-Account Support - */ - -import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { MoreHorizontal, Trash2, User } from 'lucide-react'; -import { useDeleteVariant } from '@/hooks/use-cliproxy'; -import type { Variant } from '@/lib/api-client'; - -interface CliproxyTableProps { - data: Variant[]; -} - -const providerLabels: Record = { - gemini: 'Google Gemini', - codex: 'OpenAI Codex', - agy: 'Antigravity', - qwen: 'Alibaba Qwen', - iflow: 'iFlow', -}; - -export function CliproxyTable({ data }: CliproxyTableProps) { - const deleteMutation = useDeleteVariant(); - - const columns: ColumnDef[] = [ - { - accessorKey: 'name', - header: 'Name', - cell: ({ row }) => {row.original.name}, - }, - { - accessorKey: 'provider', - header: 'Provider', - cell: ({ row }) => providerLabels[row.original.provider] || row.original.provider, - }, - { - accessorKey: 'account', - header: 'Account', - cell: ({ row }) => { - const account = row.original.account; - if (!account) { - return default; - } - return ( - - - {account} - - ); - }, - }, - { - accessorKey: 'settings', - header: 'Settings Path', - cell: ({ row }) => ( - - {row.original.settings || `config.cliproxy.${row.original.name}`} - - ), - }, - { - id: 'actions', - header: 'Actions', - cell: ({ row }) => ( -
- - - - - - deleteMutation.mutate(row.original.name)} - > - - Delete - - - -
- ), - }, - ]; - - // eslint-disable-next-line react-hooks/incompatible-library - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - }); - - if (data.length === 0) { - return ( -
-
No CLIProxy variants found.
-
- Create one to use OAuth-based providers with specific account configurations. -
-
- ); - } - - return ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} - -
-
- ); -} diff --git a/ui/src/components/code-editor.tsx b/ui/src/components/code-editor.tsx deleted file mode 100644 index 47f95edc..00000000 --- a/ui/src/components/code-editor.tsx +++ /dev/null @@ -1,221 +0,0 @@ -/** - * Code Editor Component - * Lightweight JSON editor with syntax highlighting, line numbers, and validation - * Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB) - */ - -import { useState, useCallback, useMemo } from 'react'; -import Editor from 'react-simple-code-editor'; -import { Highlight, themes } from 'prism-react-renderer'; -import { useTheme } from '@/hooks/use-theme'; -import { cn } from '@/lib/utils'; -import { isSensitiveKey } from '@/lib/sensitive-keys'; -import { AlertCircle, CheckCircle2, Eye, EyeOff } from 'lucide-react'; -import { Button } from '@/components/ui/button'; - -interface CodeEditorProps { - value: string; - onChange: (value: string) => void; - language?: 'json' | 'yaml'; - readonly?: boolean; - className?: string; - minHeight?: string; -} - -interface ValidationResult { - valid: boolean; - error?: string; - line?: number; -} - -/** - * Validate JSON and extract error location - */ -function validateJson(code: string): ValidationResult { - if (!code.trim()) { - return { valid: true }; - } - - try { - JSON.parse(code); - return { valid: true }; - } catch (e) { - const error = e as SyntaxError; - const message = error.message; - - // Try to extract line number from error message - // Format: "... at position X" or "... at line Y column Z" - const posMatch = message.match(/position (\d+)/); - if (posMatch) { - const pos = parseInt(posMatch[1], 10); - const lines = code.substring(0, pos).split('\n'); - return { - valid: false, - error: message, - line: lines.length, - }; - } - - return { - valid: false, - error: message, - }; - } -} - -export function CodeEditor({ - value, - onChange, - language = 'json', - readonly = false, - className, - minHeight = '300px', -}: CodeEditorProps) { - const { isDark } = useTheme(); - const [isFocused, setIsFocused] = useState(false); - const [isMasked, setIsMasked] = useState(true); - - // Validate on every change for JSON - const validation = useMemo(() => { - if (language === 'json') { - return validateJson(value); - } - return { valid: true }; - }, [value, language]); - - // Highlight function using prism-react-renderer - // Note: Line numbers removed - they break textarea/pre alignment in react-simple-code-editor - const highlightCode = useCallback( - (code: string) => ( - - {({ tokens, getLineProps, getTokenProps }) => { - let nextValueIsSensitive = false; - - return ( - <> - {tokens.map((line, i) => ( -
- {line.map((token, key) => { - let isSensitive = false; - - // Check for sensitive keys - if (token.types.includes('property')) { - const content = token.content.replace(/['"]/g, ''); - // Use shared sensitive key detection utility - if (isSensitiveKey(content)) { - nextValueIsSensitive = true; - } else { - nextValueIsSensitive = false; - } - } - // Apply masking to values following sensitive keys - else if ( - (token.types.includes('string') || - token.types.includes('number') || - token.types.includes('boolean')) && - nextValueIsSensitive - ) { - isSensitive = true; - // Consumes the flag for this value - nextValueIsSensitive = false; - } - // Reset flag on commas or new keys (handled by property check), - // but persist through colons and whitespace - else if (token.types.includes('punctuation')) { - if (token.content !== ':' && token.content !== '[' && token.content !== '{') { - nextValueIsSensitive = false; - } - } - - const tokenProps = getTokenProps({ token }); - - if (isSensitive && isMasked) { - tokenProps.className = cn( - tokenProps.className, - 'blur-[3px] select-none opacity-70 transition-all duration-200' - ); - } - - return ; - })} -
- ))} - - ); - }} -
- ), - [isDark, language, validation.line, isMasked] - ); - - return ( -
- {/* Editor container */} -
- {} : onChange} - highlight={highlightCode} - key={isDark ? 'dark-editor' : 'light-editor'} - padding={12} - disabled={readonly} - onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} - textareaClassName={cn( - 'focus:outline-none font-mono text-sm', - readonly && 'cursor-not-allowed' - )} - preClassName="font-mono text-sm" - style={{ - fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', - fontSize: '0.875rem', - minHeight, - }} - /> - - {/* Secrets Toggle Overlay */} -
- -
-
- - {/* Validation status */} -
- {validation.valid ? ( - - - Valid {language.toUpperCase()} - - ) : ( - - - {validation.error} - {validation.line && ` (line ${validation.line})`} - - )} - {readonly && (Read-only)} -
-
- ); -} diff --git a/ui/src/components/command-builder.tsx b/ui/src/components/command-builder.tsx deleted file mode 100644 index 8e33dae3..00000000 --- a/ui/src/components/command-builder.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { useState } from 'react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { CopyIcon, PlayIcon, TerminalIcon } from 'lucide-react'; - -interface Command { - id: string; - command: string; - description: string; - category: string; -} - -const commonCommands: Command[] = [ - { - id: '1', - command: 'ccs config', - description: 'Open configuration interface', - category: 'Config', - }, - { - id: '2', - command: 'ccs profile create --name my-profile', - description: 'Create a new profile', - category: 'Profile', - }, - { - id: '3', - command: 'ccs profile switch --name my-profile', - description: 'Switch to a profile', - category: 'Profile', - }, - { - id: '4', - command: 'ccs doctor', - description: 'Check system health', - category: 'Diagnostics', - }, - { - id: '5', - command: 'ccs cliproxy list', - description: 'List available CLIProxy providers', - category: 'CLIProxy', - }, - { - id: '6', - command: 'ccs cliproxy add --provider gemini --token YOUR_TOKEN', - description: 'Add CLIProxy provider', - category: 'CLIProxy', - }, -]; - -export function CommandBuilder() { - const [command, setCommand] = useState(''); - const [filteredCommands, setFilteredCommands] = useState(commonCommands); - - const handleCommandChange = (value: string) => { - setCommand(value); - const filtered = commonCommands.filter( - (cmd) => - cmd.command.toLowerCase().includes(value.toLowerCase()) || - cmd.description.toLowerCase().includes(value.toLowerCase()) - ); - setFilteredCommands(filtered); - }; - - const handleCommandSelect = (cmd: string) => { - setCommand(cmd); - setFilteredCommands(commonCommands); - }; - - const handleCopy = () => { - navigator.clipboard.writeText(command); - }; - - const handleRun = () => { - console.log('Running command:', command); - }; - - const categories = Array.from(new Set(commonCommands.map((cmd) => cmd.category))); - - return ( - - - - - Command Builder - - - -
- handleCommandChange(e.target.value)} - className="font-mono" - /> -
- - -
-
- -
- {categories.map((category) => ( - { - const categoryCommands = commonCommands.filter((cmd) => cmd.category === category); - console.log(`${category} commands:`, categoryCommands); - }} - > - {category} - - ))} -
- - -
- {filteredCommands.map((cmd) => ( -
handleCommandSelect(cmd.command)} - > -
{cmd.command}
-
{cmd.description}
- - {cmd.category} - -
- ))} -
-
-
-
- ); -} diff --git a/ui/src/components/confirm-dialog.tsx b/ui/src/components/confirm-dialog.tsx deleted file mode 100644 index 43d7f940..00000000 --- a/ui/src/components/confirm-dialog.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; - -interface ConfirmDialogProps { - open: boolean; - onConfirm: () => void; - onCancel: () => void; - title: string; - description: string; - confirmText?: string; - variant?: 'default' | 'destructive'; -} - -export function ConfirmDialog({ - open, - onConfirm, - onCancel, - title, - description, - confirmText = 'Confirm', - variant = 'default', -}: ConfirmDialogProps) { - return ( - !isOpen && onCancel()}> - - - {title} - {description} - - - Cancel - - {confirmText} - - - - - ); -} diff --git a/ui/src/components/connection-indicator.tsx b/ui/src/components/connection-indicator.tsx deleted file mode 100644 index eb00487d..00000000 --- a/ui/src/components/connection-indicator.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Connection Indicator (Phase 04) - * - * Shows WebSocket connection status in the header. - */ - -import { Wifi, WifiOff } from 'lucide-react'; -import { useWebSocket } from '@/hooks/use-websocket'; - -export function ConnectionIndicator() { - const { status } = useWebSocket(); - - const statusConfig = { - connected: { icon: Wifi, color: 'text-green-600', label: 'Connected' }, - connecting: { icon: Wifi, color: 'text-yellow-500', label: 'Connecting...' }, - disconnected: { icon: WifiOff, color: 'text-red-500', label: 'Disconnected' }, - }; - - const config = statusConfig[status]; - const Icon = config.icon; - - return ( -
- - {config.label} -
- ); -} diff --git a/ui/src/components/docs-link.tsx b/ui/src/components/docs-link.tsx deleted file mode 100644 index 72e16014..00000000 --- a/ui/src/components/docs-link.tsx +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Docs Link Button - * - * Links to CCS documentation site for guides and reference. - */ - -import { BookOpen } from 'lucide-react'; -import { Button } from '@/components/ui/button'; - -const DOCS_URL = 'https://docs.ccs.kaitran.ca'; - -export function DocsLink() { - return ( - - ); -} diff --git a/ui/src/components/github-link.tsx b/ui/src/components/github-link.tsx deleted file mode 100644 index 8ece4cab..00000000 --- a/ui/src/components/github-link.tsx +++ /dev/null @@ -1,20 +0,0 @@ -/** - * GitHub Link Button - * - * Links to CCS GitHub issues page for bug reports and feature requests. - */ - -import { Github } from 'lucide-react'; -import { Button } from '@/components/ui/button'; - -const GITHUB_REPO_URL = 'https://github.com/kaitranntt/ccs/issues'; - -export function GitHubLink() { - return ( - - ); -} diff --git a/ui/src/components/global-env-indicator.tsx b/ui/src/components/global-env-indicator.tsx deleted file mode 100644 index b6cc0cf3..00000000 --- a/ui/src/components/global-env-indicator.tsx +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Global Environment Variables Indicator - * - * Shows which env vars from global_env will be injected at runtime. - * Displayed below the Raw Configuration (JSON) section in profile editors. - */ - -import { useState, useEffect } from 'react'; -import { Link } from 'react-router-dom'; -import { Settings2, ChevronDown, ChevronUp, ExternalLink, Info } from 'lucide-react'; -import { Button } from '@/components/ui/button'; - -interface GlobalEnvConfig { - enabled: boolean; - env: Record; -} - -interface GlobalEnvIndicatorProps { - /** Current profile's env vars (to show which are overridden) */ - profileEnv?: Record; -} - -export function GlobalEnvIndicator({ profileEnv = {} }: GlobalEnvIndicatorProps) { - const [config, setConfig] = useState(null); - const [loading, setLoading] = useState(true); - const [expanded, setExpanded] = useState(false); - - useEffect(() => { - fetchConfig(); - }, []); - - const fetchConfig = async () => { - try { - setLoading(true); - const res = await fetch('/api/global-env'); - if (!res.ok) throw new Error('Failed to load'); - const data = await res.json(); - setConfig(data); - } catch { - setConfig(null); - } finally { - setLoading(false); - } - }; - - // Don't render if loading or disabled or no vars - if (loading) return null; - if (!config?.enabled) return null; - - const envVars = config.env || {}; - const envKeys = Object.keys(envVars); - if (envKeys.length === 0) return null; - - // Check which keys are already in profile (won't be overridden) - const injectedKeys = envKeys.filter((key) => !(key in profileEnv)); - const overriddenKeys = envKeys.filter((key) => key in profileEnv); - - return ( -
- {/* Header - clickable to expand */} - - - {/* Expanded content */} - {expanded && ( -
- {/* Injected vars */} - {injectedKeys.length > 0 && ( -
- {injectedKeys.map((key) => ( -
- + - - {key}={envVars[key]} - -
- ))} -
- )} - - {/* Overridden vars (profile takes precedence) */} - {overriddenKeys.length > 0 && ( -
-

Skipped (profile already defines):

- {overriddenKeys.map((key) => ( -
- ~ - {key} -
- ))} -
- )} - - {/* Link to settings */} -
- -
-
- )} -
- ); -} diff --git a/ui/src/components/health-card.tsx b/ui/src/components/health-card.tsx deleted file mode 100644 index c3097596..00000000 --- a/ui/src/components/health-card.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Card, CardContent } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { CheckCircle, AlertTriangle, XCircle, Wrench } from 'lucide-react'; -import { useFixHealth } from '@/hooks/use-health'; - -interface HealthCheck { - id: string; - name: string; - status: 'ok' | 'warning' | 'error'; - message: string; - details?: string; - fixable?: boolean; -} - -const statusConfig = { - ok: { - icon: CheckCircle, - color: 'text-green-600', - bg: 'bg-green-50 dark:bg-green-900/20', - border: 'border-green-200 dark:border-green-800', - }, - warning: { - icon: AlertTriangle, - color: 'text-yellow-500', - bg: 'bg-yellow-50 dark:bg-yellow-900/20', - border: 'border-yellow-200 dark:border-yellow-800', - }, - error: { - icon: XCircle, - color: 'text-red-500', - bg: 'bg-red-50 dark:bg-red-900/20', - border: 'border-red-200 dark:border-red-800', - }, -}; - -export function HealthCard({ check }: { check: HealthCheck }) { - const fixMutation = useFixHealth(); - const config = statusConfig[check.status]; - const Icon = config.icon; - - return ( - - -
-
- - {check.name} -
- {check.fixable && check.status !== 'ok' && ( - - )} -
-

{check.message}

- {check.details && ( -

{check.details}

- )} -
-
- ); -} diff --git a/ui/src/components/health-check-item.tsx b/ui/src/components/health-check-item.tsx deleted file mode 100644 index 88268d64..00000000 --- a/ui/src/components/health-check-item.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { ChevronRight, Copy, Terminal, Wrench } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; -import { useFixHealth, type HealthCheck } from '@/hooks/use-health'; -import { cn } from '@/lib/utils'; -import { useState } from 'react'; -import { toast } from 'sonner'; - -const statusConfig = { - ok: { dot: 'bg-green-500', label: 'OK', labelColor: 'text-green-500' }, - warning: { dot: 'bg-yellow-500', label: 'WARN', labelColor: 'text-yellow-500' }, - error: { dot: 'bg-red-500', label: 'ERR', labelColor: 'text-red-500' }, - info: { dot: 'bg-blue-500', label: 'INFO', labelColor: 'text-blue-500' }, -}; - -export function HealthCheckItem({ check }: { check: HealthCheck }) { - const fixMutation = useFixHealth(); - const config = statusConfig[check.status]; - const [isOpen, setIsOpen] = useState(false); - const hasExpandableContent = check.details || check.fix; - - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text); - toast.success('Copied to clipboard'); - }; - - // Compact single-line display for items without expandable content - if (!hasExpandableContent) { - return ( -
- {/* Status dot with pulse animation */} -
-
- {check.status !== 'ok' && ( -
- )} -
- - {/* Check name */} - {check.name} - - {/* Status label */} - - [{config.label}] - - - {/* Fix button for fixable non-ok items */} - {check.fixable && check.status !== 'ok' && ( - - )} -
- ); - } - - // Expandable display for items with details or fix commands - return ( - -
- - - - - -
- {/* Message */} -

{check.message}

- - {/* Details block */} - {check.details && ( -
-                {check.details}
-              
- )} - - {/* Fix command block */} - {check.fix && ( -
-
- - {check.fix} - -
- - {check.fixable && check.status !== 'ok' && ( - - )} -
- )} -
-
-
-
- ); -} diff --git a/ui/src/components/health-gauge.tsx b/ui/src/components/health-gauge.tsx deleted file mode 100644 index 2936b726..00000000 --- a/ui/src/components/health-gauge.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { cn } from '@/lib/utils'; - -interface HealthGaugeProps { - passed: number; - total: number; - status: 'ok' | 'warning' | 'error'; - size?: 'sm' | 'md' | 'lg'; -} - -const sizeConfig = { - sm: { dimension: 80, strokeWidth: 6, fontSize: 'text-lg', labelSize: 'text-[10px]' }, - md: { dimension: 120, strokeWidth: 8, fontSize: 'text-3xl', labelSize: 'text-xs' }, - lg: { dimension: 160, strokeWidth: 10, fontSize: 'text-4xl', labelSize: 'text-sm' }, -}; - -const statusColors = { - ok: { stroke: '#22C55E', glow: 'rgba(34, 197, 94, 0.4)' }, - warning: { stroke: '#EAB308', glow: 'rgba(234, 179, 8, 0.4)' }, - error: { stroke: '#EF4444', glow: 'rgba(239, 68, 68, 0.4)' }, -}; - -export function HealthGauge({ passed, total, status, size = 'md' }: HealthGaugeProps) { - const config = sizeConfig[size]; - const colors = statusColors[status]; - const percentage = total > 0 ? Math.round((passed / total) * 100) : 0; - - const radius = (config.dimension - config.strokeWidth) / 2; - const circumference = 2 * Math.PI * radius; - const strokeDashoffset = circumference - (percentage / 100) * circumference; - const center = config.dimension / 2; - - return ( -
- - {/* Background track */} - - {/* Progress arc */} - - {/* Animated glow dot at end of arc */} - {percentage > 0 && ( - - )} - - {/* Center content */} -
- - {percentage} - - - health - -
-
- ); -} diff --git a/ui/src/components/health-group-section.tsx b/ui/src/components/health-group-section.tsx deleted file mode 100644 index 9b0e4d30..00000000 --- a/ui/src/components/health-group-section.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { ChevronDown, Monitor, Settings, Users, Shield, Zap } from 'lucide-react'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; -import { HealthCheckItem } from '@/components/health-check-item'; -import { type HealthGroup } from '@/hooks/use-health'; -import { cn } from '@/lib/utils'; -import { useState } from 'react'; - -const groupIcons: Record = { - Monitor, - Settings, - Users, - Shield, - Zap, -}; - -interface HealthGroupSectionProps { - group: HealthGroup; - defaultOpen?: boolean; -} - -export function HealthGroupSection({ group, defaultOpen = true }: HealthGroupSectionProps) { - const [isOpen, setIsOpen] = useState(defaultOpen); - const Icon = groupIcons[group.icon] || Monitor; - - const passed = group.checks.filter((c) => c.status === 'ok').length; - const total = group.checks.length; - const hasErrors = group.checks.some((c) => c.status === 'error'); - const hasWarnings = group.checks.some((c) => c.status === 'warning'); - const percentage = Math.round((passed / total) * 100); - - // Determine status color - const statusColor = hasErrors - ? 'text-red-500' - : hasWarnings - ? 'text-yellow-500' - : 'text-green-500'; - const progressColor = hasErrors ? 'bg-red-500' : hasWarnings ? 'bg-yellow-500' : 'bg-green-500'; - - return ( - -
- {/* Group header */} - - - - - {/* Checks list */} - -
- {group.checks.map((check) => ( - - ))} -
-
-
-
- ); -} diff --git a/ui/src/components/health-stats-bar.tsx b/ui/src/components/health-stats-bar.tsx deleted file mode 100644 index 042a3f89..00000000 --- a/ui/src/components/health-stats-bar.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { cn } from '@/lib/utils'; - -interface HealthStatsBarProps { - total: number; - passed: number; - warnings: number; - errors: number; - info: number; -} - -interface StatItemProps { - label: string; - value: number; - color: string; - bgColor: string; -} - -function StatItem({ label, value, color, bgColor }: StatItemProps) { - return ( -
-
- - {label} - - {value} -
- ); -} - -export function HealthStatsBar({ total, passed, warnings, errors, info }: HealthStatsBarProps) { - // Calculate percentages for the progress bar - const passedPct = (passed / total) * 100; - const warningPct = (warnings / total) * 100; - const errorPct = (errors / total) * 100; - const infoPct = (info / total) * 100; - - return ( -
- {/* Progress bar visualization */} -
- {errorPct > 0 && ( -
- )} - {warningPct > 0 && ( -
- )} - {infoPct > 0 && ( -
- )} - {passedPct > 0 && ( -
- )} -
- - {/* Stats row */} -
-
- - Checks - - {total} -
- -
- - - - -
-
-
- ); -} diff --git a/ui/src/components/hero-section.tsx b/ui/src/components/hero-section.tsx deleted file mode 100644 index 47b49b91..00000000 --- a/ui/src/components/hero-section.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Badge } from '@/components/ui/badge'; -import { CcsLogo } from '@/components/ccs-logo'; - -interface HeroSectionProps { - version?: string; -} - -export function HeroSection({ version = '5.0.0' }: HeroSectionProps) { - return ( -
- -
-
-

CCS Config

- - v{version} - -
-

Claude Code Switch Dashboard

-
-
- ); -} diff --git a/ui/src/components/hub-footer.tsx b/ui/src/components/hub-footer.tsx deleted file mode 100644 index a81539f8..00000000 --- a/ui/src/components/hub-footer.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Separator } from '@/components/ui/separator'; -import { Button } from '@/components/ui/button'; -import { FileTextIcon, SettingsIcon, GithubIcon, ExternalLinkIcon } from 'lucide-react'; - -export function HubFooter() { - const currentYear = new Date().getFullYear(); - - const footerLinks = [ - { - icon: , - label: 'Logs', - href: '#logs', - onClick: () => console.log('Navigate to Logs'), - }, - { - icon: , - label: 'Settings', - href: '#settings', - onClick: () => console.log('Navigate to Settings'), - }, - { - icon: , - label: 'GitHub', - href: 'https://github.com/kaitranntt/ccs', - external: true, - }, - ]; - - return ( -
-
-
- CCS v0.0.0 - - © {currentYear} kaitranntt -
- -
- {footerLinks.map((link) => ( - - ))} -
-
-
- ); -} diff --git a/ui/src/components/layout.tsx b/ui/src/components/layout.tsx deleted file mode 100644 index 41002086..00000000 --- a/ui/src/components/layout.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Suspense } from 'react'; -import { Outlet } from 'react-router-dom'; -import { SidebarProvider } from '@/components/ui/sidebar'; -import { AppSidebar } from '@/components/app-sidebar'; -import { ThemeToggle } from '@/components/theme-toggle'; -import { PrivacyToggle } from '@/components/privacy-toggle'; -import { GitHubLink } from '@/components/github-link'; -import { DocsLink } from '@/components/docs-link'; -import { ConnectionIndicator } from '@/components/connection-indicator'; -import { LocalhostDisclaimer } from '@/components/localhost-disclaimer'; -import { Skeleton } from '@/components/ui/skeleton'; -import { ClaudeKitBadge } from '@/components/claudekit-badge'; -import { SponsorButton } from '@/components/sponsor-button'; -import { ProjectSelectionDialog } from '@/components/project-selection-dialog'; -import { useProjectSelection } from '@/hooks/use-project-selection'; - -function PageLoader() { - return ( -
- - -
- ); -} - -export function Layout() { - const { isOpen, prompt, onSelect, onClose } = useProjectSelection(); - - return ( - - -
-
-
- - -
-
- - - - - -
-
-
- }> - - -
- -
- - {/* Global project selection dialog for OAuth flows */} - {prompt && ( - - )} -
- ); -} diff --git a/ui/src/components/localhost-disclaimer.tsx b/ui/src/components/localhost-disclaimer.tsx deleted file mode 100644 index 78d5c6e1..00000000 --- a/ui/src/components/localhost-disclaimer.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Shield, X } from 'lucide-react'; -import { useState } from 'react'; - -export function LocalhostDisclaimer() { - const [dismissed, setDismissed] = useState(false); - - if (dismissed) return null; - - return ( -
-
-
- - - This dashboard runs locally. All data stays on your machine. - - Local dashboard - data stays on your device. -
- -
-
- ); -} diff --git a/ui/src/components/privacy-toggle.tsx b/ui/src/components/privacy-toggle.tsx deleted file mode 100644 index eb70362d..00000000 --- a/ui/src/components/privacy-toggle.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Privacy Toggle Button - * Toggles demo mode to blur personal information (emails, account IDs) - */ - -import { Button } from '@/components/ui/button'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { usePrivacy } from '@/contexts/privacy-context'; -import { Eye, EyeOff } from 'lucide-react'; -import { cn } from '@/lib/utils'; - -export function PrivacyToggle() { - const { privacyMode, togglePrivacyMode } = usePrivacy(); - - return ( - - - - - -

- {privacyMode - ? 'Privacy mode ON - Click to show data' - : 'Privacy mode OFF - Click to hide data'} -

-
-
- ); -} diff --git a/ui/src/components/profile-card.tsx b/ui/src/components/profile-card.tsx deleted file mode 100644 index b760c392..00000000 --- a/ui/src/components/profile-card.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Card, CardContent, CardHeader } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { SettingsIcon, PlayIcon } from 'lucide-react'; - -interface ProfileCardProps { - profile: { - name: string; - settingsPath: string; - configured: boolean; - isActive?: boolean; - lastUsed?: string; - model?: string; - }; - onSwitch?: () => void; - onConfig?: () => void; - onTest?: () => void; -} - -export function ProfileCard({ profile, onSwitch, onConfig, onTest }: ProfileCardProps) { - return ( - - -
-
-

{profile.name}

- {profile.isActive && ( - - Active - - )} -
- -
-
- - {profile.model && ( -
Model: {profile.model}
- )} - {profile.lastUsed && ( -
Last used: {profile.lastUsed}
- )} -
- - -
-
-
- ); -} diff --git a/ui/src/components/profile-create-dialog.tsx b/ui/src/components/profile-create-dialog.tsx deleted file mode 100644 index 5506bd45..00000000 --- a/ui/src/components/profile-create-dialog.tsx +++ /dev/null @@ -1,344 +0,0 @@ -/** - * Profile Create Dialog Component - * Modal dialog with tabbed interface for creating new API profiles - * Includes Quick Start templates and advanced model configuration - */ - -import { useState, useEffect } from 'react'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Badge } from '@/components/ui/badge'; -import { useCreateProfile } from '@/hooks/use-profiles'; -import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff } from 'lucide-react'; -import { toast } from 'sonner'; -import { cn } from '@/lib/utils'; - -const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929'; - -const schema = z.object({ - name: z - .string() - .min(1, 'Name is required') - .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Must start with letter, only letters/numbers/.-_'), - baseUrl: z.string().url('Invalid URL format'), - apiKey: z.string().min(1, 'API key is required'), - model: z.string().optional(), - opusModel: z.string().optional(), - sonnetModel: z.string().optional(), - haikuModel: z.string().optional(), -}); - -type FormData = z.infer; - -interface ProfileCreateDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - onSuccess: (name: string) => void; -} - -// Common URL mistakes to warn about -const PROBLEMATIC_PATHS = ['/chat/completions', '/v1/messages', '/messages', '/completions']; - -export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCreateDialogProps) { - const createMutation = useCreateProfile(); - const [activeTab, setActiveTab] = useState('basic'); - const [urlWarning, setUrlWarning] = useState(null); - const [showApiKey, setShowApiKey] = useState(false); - - const { - register, - handleSubmit, - formState: { errors }, - watch, - reset, - } = useForm({ - resolver: zodResolver(schema), - defaultValues: { - name: '', - baseUrl: '', - apiKey: '', - model: '', - opusModel: '', - sonnetModel: '', - haikuModel: '', - }, - }); - - const baseUrlValue = watch('baseUrl'); - - // Reset form when dialog opens - useEffect(() => { - if (open) { - reset(); - setActiveTab('basic'); - setUrlWarning(null); - setShowApiKey(false); - } - }, [open, reset]); - - // Check for common URL mistakes - useEffect(() => { - if (baseUrlValue) { - const lowerUrl = baseUrlValue.toLowerCase(); - for (const path of PROBLEMATIC_PATHS) { - if (lowerUrl.endsWith(path)) { - const suggestedUrl = baseUrlValue.replace(new RegExp(path + '$', 'i'), ''); - setUrlWarning( - `URL ends with "${path}" - Claude appends this automatically. You likely want: ${suggestedUrl}` - ); - return; - } - } - } - setUrlWarning(null); - }, [baseUrlValue]); - - const onSubmit = async (data: FormData) => { - try { - await createMutation.mutateAsync(data); - toast.success(`Profile "${data.name}" created`); - onSuccess(data.name); - onOpenChange(false); - } catch (error) { - toast.error((error as Error).message || 'Failed to create profile'); - } - }; - - const hasBasicErrors = !!errors.name || !!errors.baseUrl || !!errors.apiKey; - const hasModelErrors = - !!errors.model || !!errors.opusModel || !!errors.sonnetModel || !!errors.haikuModel; - - return ( - - - - - - Create API Profile - - Configure a custom API endpoint for Claude Code. - - -
- -
- - - Basic Information - {hasBasicErrors && ( - - )} - - - Model Configuration - {hasModelErrors && ( - - )} - - -
- -
- -
- {/* Name */} -
- - - {errors.name ? ( -

{errors.name.message}

- ) : ( -

- Used in CLI:{' '} - - ccs my-api "prompt" - -

- )} -
- - {/* Base URL */} -
- - - {errors.baseUrl ? ( -

{errors.baseUrl.message}

- ) : urlWarning ? ( -
- - {urlWarning} -
- ) : ( -

- The endpoint that accepts OpenAI-compatible and Anthropic requests -

- )} -
- - {/* API Key */} -
- -
- - -
- {errors.apiKey && ( -

{errors.apiKey.message}

- )} -
-
-
- - -
- -
-

Model Mapping

-

- Claude Code requests specific model tiers (Opus/Sonnet/Haiku). Map these tiers - to the specific models supported by your API provider. -

-
-
- -
-
- - -

- Fallback model if no specific tier is requested -

-
- -
-
- - -
- -
- - -
- -
- - -
-
-
-
-
- - - - - -
-
-
-
- ); -} diff --git a/ui/src/components/profile-deck.tsx b/ui/src/components/profile-deck.tsx deleted file mode 100644 index 4a3255b0..00000000 --- a/ui/src/components/profile-deck.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useProfiles } from '@/hooks/use-profiles'; -import { ProfileCard } from './profile-card'; -import { Skeleton } from '@/components/ui/skeleton'; - -export function ProfileDeck() { - const { data: response, isLoading, error } = useProfiles(); - - if (isLoading) { - return ( -
- {[...Array(6)].map((_, i) => ( -
- -
- ))} -
- ); - } - - if (error) { - return
Failed to load profiles: {error.message}
; - } - - const profiles = response?.profiles || []; - - if (!profiles || profiles.length === 0) { - return ( -
- No profiles configured. Create your first profile to get started. -
- ); - } - - // Use real profile data directly - const normalizedProfiles = profiles.map((profile) => ({ - ...profile, - configured: profile.configured ?? false, // Ensure configured is always boolean - })); - - return ( -
-

Profiles

-
- {normalizedProfiles.map((profile) => ( - console.log('Switch to', profile.name)} - onConfig={() => console.log('Config', profile.name)} - onTest={() => console.log('Test', profile.name)} - /> - ))} -
-
- ); -} diff --git a/ui/src/components/profile-dialog.tsx b/ui/src/components/profile-dialog.tsx deleted file mode 100644 index 183f2f24..00000000 --- a/ui/src/components/profile-dialog.tsx +++ /dev/null @@ -1,224 +0,0 @@ -/** - * Profile Dialog Component - * Phase 03: REST API Routes & CRUD - * Updated: Added model mapping fields for Opus/Sonnet/Haiku - */ - -import { useState, useEffect } from 'react'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { useCreateProfile, useUpdateProfile } from '@/hooks/use-profiles'; -import type { Profile } from '@/lib/api-client'; -import { ChevronDown, ChevronRight } from 'lucide-react'; - -const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929'; - -const schema = z.object({ - name: z - .string() - .min(1, 'Name is required') - .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid profile name'), - baseUrl: z.string().url('Invalid URL'), - apiKey: z.string().min(10, 'API key must be at least 10 characters'), - model: z.string().optional(), - opusModel: z.string().optional(), - sonnetModel: z.string().optional(), - haikuModel: z.string().optional(), -}); - -type FormData = z.infer; - -interface ProfileDialogProps { - open: boolean; - onClose: () => void; - profile?: Profile | null; -} - -export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { - const createMutation = useCreateProfile(); - const updateMutation = useUpdateProfile(); - const [showModelMapping, setShowModelMapping] = useState(false); - - const { - register, - handleSubmit, - formState: { errors }, - reset, - watch, - } = useForm({ - resolver: zodResolver(schema), - defaultValues: profile - ? { - name: profile.name, - baseUrl: '', - apiKey: '', - model: '', - opusModel: '', - sonnetModel: '', - haikuModel: '', - } - : undefined, - }); - - // Watch model field to auto-expand model mapping when custom model is entered - const modelValue = watch('model'); - - useEffect(() => { - // Auto-show model mapping if user enters a custom model (not default) - if (modelValue && modelValue !== DEFAULT_MODEL && modelValue.trim() !== '') { - setShowModelMapping(true); - } - }, [modelValue]); - - // Reset state when dialog opens/closes - useEffect(() => { - if (!open) { - setShowModelMapping(false); - } - }, [open]); - - const onSubmit = async (data: FormData) => { - try { - if (profile) { - // Update mode - await updateMutation.mutateAsync({ - name: profile.name, - data: { - baseUrl: data.baseUrl, - apiKey: data.apiKey, - model: data.model, - opusModel: data.opusModel, - sonnetModel: data.sonnetModel, - haikuModel: data.haikuModel, - }, - }); - } else { - // Create mode - await createMutation.mutateAsync(data); - } - reset(); - onClose(); - } catch (error) { - // Error is handled by the mutation hooks - console.error('Failed to save profile:', error); - } - }; - - return ( - - - - {profile ? 'Edit Profile' : 'Create API Profile'} - -
-
- - - {errors.name && {errors.name.message}} -
- -
- - - {errors.baseUrl && ( - {errors.baseUrl.message} - )} -
- -
- - - {errors.apiKey && {errors.apiKey.message}} -
- -
- - -

- Leave blank to use: {DEFAULT_MODEL} -

-
- - {/* Model Mapping Section */} -
- - - {showModelMapping && ( -
-

- Configure different model IDs for each tier. Useful for API proxies that route - different model types to different backends. -

- -
- - -
- -
- - -
- -
- - -
-
- )} -
- -
- - -
-
-
-
- ); -} diff --git a/ui/src/components/profiles-table.tsx b/ui/src/components/profiles-table.tsx deleted file mode 100644 index c09ef990..00000000 --- a/ui/src/components/profiles-table.tsx +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Profiles Table Component - * Phase 03: REST API Routes & CRUD - */ - -import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { MoreHorizontal, Trash2, Edit } from 'lucide-react'; -import { useDeleteProfile } from '@/hooks/use-profiles'; -import type { Profile } from '@/lib/api-client'; - -interface ProfilesTableProps { - data: Profile[]; - onEditSettings?: (profile: Profile) => void; -} - -export function ProfilesTable({ data, onEditSettings }: ProfilesTableProps) { - const deleteMutation = useDeleteProfile(); - - const columns: ColumnDef[] = [ - { - accessorKey: 'name', - header: 'Name', - size: 200, - }, - { - accessorKey: 'settingsPath', - header: 'Settings Path', - }, - { - accessorKey: 'configured', - header: 'Status', - size: 100, - cell: ({ row }) => ( - - {row.original.configured ? '[OK]' : '[!]'} - - ), - }, - { - id: 'actions', - header: 'Actions', - size: 100, - cell: ({ row }) => ( - - - - - - {onEditSettings && ( - onEditSettings(row.original)}> - - Edit - - )} - - deleteMutation.mutate(row.original.name)} - > - - Delete - - - - ), - }, - ]; - - // eslint-disable-next-line react-hooks/incompatible-library - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - }); - - if (data.length === 0) { - return ( -
- No profiles found. Create one to get started. -
- ); - } - - return ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const isAction = header.id === 'actions'; - const isStatus = header.id === 'configured'; - const isName = header.id === 'name'; - - return ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ); - })} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} - -
-
- ); -} diff --git a/ui/src/components/project-selection-dialog.tsx b/ui/src/components/project-selection-dialog.tsx deleted file mode 100644 index b80b00b9..00000000 --- a/ui/src/components/project-selection-dialog.tsx +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Project Selection Dialog Component - * - * Displays during OAuth flow when CLIProxyAPI requires user to select - * a Google Cloud project. Shows list of available projects with option - * to select one or ALL. - */ - -import { useState, useEffect } from 'react'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Loader2, FolderOpen, Check, Circle, CheckCircle } from 'lucide-react'; - -interface GCloudProject { - id: string; - name: string; - index: number; -} - -interface ProjectSelectionDialogProps { - open: boolean; - onClose: () => void; - sessionId: string; - provider: string; - projects: GCloudProject[]; - defaultProjectId: string; - supportsAll: boolean; - onSelect: (selectedId: string) => Promise; - /** Timeout in seconds before auto-selecting default */ - timeoutSeconds?: number; -} - -export function ProjectSelectionDialog({ - open, - onClose, - sessionId, - provider, - projects, - defaultProjectId, - supportsAll, - onSelect, - timeoutSeconds = 30, -}: ProjectSelectionDialogProps) { - const [selectedId, setSelectedId] = useState(defaultProjectId); - const [isSubmitting, setIsSubmitting] = useState(false); - const [countdown, setCountdown] = useState(timeoutSeconds); - - // Countdown timer for auto-selection - useEffect(() => { - if (!open) { - setCountdown(timeoutSeconds); - return; - } - - const timer = setInterval(() => { - setCountdown((prev) => { - if (prev <= 1) { - clearInterval(timer); - // Auto-submit on timeout - handleSubmit(true); - return 0; - } - return prev - 1; - }); - }, 1000); - - return () => clearInterval(timer); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, timeoutSeconds]); - - // Reset state when dialog opens - useEffect(() => { - if (open) { - setSelectedId(defaultProjectId); - setIsSubmitting(false); - setCountdown(timeoutSeconds); - } - }, [open, defaultProjectId, timeoutSeconds]); - - const handleSubmit = async (isTimeout = false) => { - if (isSubmitting) return; - setIsSubmitting(true); - - try { - // Empty string means use default (press Enter behavior) - const submitId = isTimeout ? '' : selectedId; - await onSelect(submitId); - onClose(); - } catch (error) { - console.error('Failed to submit project selection:', error); - // On error, submit empty to use default - try { - await onSelect(''); - } catch { - // Ignore double-error - } - onClose(); - } - }; - - const providerDisplay = provider.charAt(0).toUpperCase() + provider.slice(1); - - // Suppress unused variable warning - sessionId used for identification - void sessionId; - - return ( - !isOpen && !isSubmitting && onClose()}> - - - - - Select Google Cloud Project - - - Choose which project to use for {providerDisplay} authentication. - {countdown > 0 && ( - - (Auto-selecting default in {countdown}s) - - )} - - - -
-
- {projects.map((project) => ( -
!isSubmitting && setSelectedId(project.id)} - > - {selectedId === project.id ? ( - - ) : ( - - )} -
-
{project.name}
-
{project.id}
-
- {project.id === defaultProjectId && ( - Default - )} -
- ))} - - {supportsAll && ( -
!isSubmitting && setSelectedId('ALL')} - > - {selectedId === 'ALL' ? ( - - ) : ( - - )} -
-
All Projects
-
- Onboard all {projects.length} listed projects -
-
-
- )} -
- -
- - -
-
-
-
- ); -} diff --git a/ui/src/components/provider-icon.tsx b/ui/src/components/provider-icon.tsx deleted file mode 100644 index 828f649e..00000000 --- a/ui/src/components/provider-icon.tsx +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Provider Icon Component - * Renders provider logos from /assets/providers/ - * Supports white background circle variant for dark themes - */ - -import { cn } from '@/lib/utils'; -import { PROVIDER_ASSETS, PROVIDER_COLORS } from '@/lib/provider-config'; - -interface ProviderIconProps { - provider: string; - className?: string; - size?: number; - /** White background circle variant for better visibility */ - withBackground?: boolean; -} - -export function ProviderIcon({ - provider, - className, - size = 18, - withBackground = false, -}: ProviderIconProps) { - const normalized = provider.toLowerCase(); - const assetPath = PROVIDER_ASSETS[normalized]; - - // Icon size is smaller when inside background circle - const iconSize = withBackground ? Math.floor(size * 0.65) : size; - - const iconElement = assetPath ? ( - {`${provider} - ) : ( - // Fallback: colored text letter - - {provider.charAt(0).toUpperCase()} - - ); - - if (withBackground) { - return ( -
- {iconElement} -
- ); - } - - // Without background - original behavior for logos, colored circle for fallback - if (assetPath) { - return ( - {`${provider} - ); - } - - const bgColor = PROVIDER_COLORS[normalized] || '#6b7280'; - return ( -
- {provider.charAt(0).toUpperCase()} -
- ); -} diff --git a/ui/src/components/proxy-status-widget.tsx b/ui/src/components/proxy-status-widget.tsx deleted file mode 100644 index bfa0a2c3..00000000 --- a/ui/src/components/proxy-status-widget.tsx +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Proxy Status Widget - * - * Displays CLIProxy process status with start/stop/restart controls. - * Shows: running state, port, session count, uptime, update availability. - */ - -import { Activity, Power, RefreshCw, Clock, Users, Square, RotateCw, ArrowUp } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { - useProxyStatus, - useStartProxy, - useStopProxy, - useCliproxyUpdateCheck, -} from '@/hooks/use-cliproxy'; -import { cn } from '@/lib/utils'; - -function formatUptime(startedAt?: string): string { - if (!startedAt) return ''; - const start = new Date(startedAt).getTime(); - const now = Date.now(); - const diff = now - start; - - const hours = Math.floor(diff / (1000 * 60 * 60)); - const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); - - if (hours > 0) { - return `${hours}h ${minutes}m`; - } - return `${minutes}m`; -} - -function formatTimeAgo(timestamp?: number): string { - if (!timestamp) return ''; - const diff = Date.now() - timestamp; - const minutes = Math.floor(diff / (1000 * 60)); - const hours = Math.floor(diff / (1000 * 60 * 60)); - - if (minutes < 1) return 'just now'; - if (minutes < 60) return `${minutes}m ago`; - return `${hours}h ago`; -} - -export function ProxyStatusWidget() { - const { data: status, isLoading } = useProxyStatus(); - const { data: updateCheck } = useCliproxyUpdateCheck(); - const startProxy = useStartProxy(); - const stopProxy = useStopProxy(); - - const isRunning = status?.running ?? false; - const isActioning = startProxy.isPending || stopProxy.isPending; - const hasUpdate = updateCheck?.hasUpdate ?? false; - - // Restart = stop then start - const handleRestart = async () => { - await stopProxy.mutateAsync(); - // Small delay to ensure port is released - await new Promise((r) => setTimeout(r, 500)); - startProxy.mutate(); - }; - - return ( -
-
-
-
- CLIProxy Service - {hasUpdate && ( - v${updateCheck?.latestVersion}`} - > - - Update - - )} -
- -
- {isLoading ? ( - - ) : isRunning ? ( - - ) : ( - - )} -
-
- - {isRunning && status ? ( - <> -
- Port {status.port} - {status.sessionCount !== undefined && status.sessionCount > 0 && ( - - - {status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''} - - )} - {status.startedAt && ( - - - {formatUptime(status.startedAt)} - - )} -
- {/* Control buttons when running */} -
- - -
- - ) : ( -
- Not running - -
- )} - - {/* Version sync indicator */} - {updateCheck?.currentVersion && ( -
- v{updateCheck.currentVersion} - {updateCheck.checkedAt && ( - - Synced {formatTimeAgo(updateCheck.checkedAt)} - - )} -
- )} -
- ); -} diff --git a/ui/src/components/quick-commands.tsx b/ui/src/components/quick-commands.tsx deleted file mode 100644 index 6ab4d6f8..00000000 --- a/ui/src/components/quick-commands.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Copy, Check, Terminal } from 'lucide-react'; -import { useState } from 'react'; -import { cn } from '@/lib/utils'; - -interface CommandSnippet { - label: string; - command: string; - description: string; -} - -const defaultSnippets: CommandSnippet[] = [ - { - label: 'Start Default', - command: 'ccs', - description: 'Launch Claude with default profile', - }, - { - label: 'GLM Profile', - command: 'ccs glm', - description: 'Switch to GLM model', - }, - { - label: 'Health Check', - command: 'ccs doctor', - description: 'Run system diagnostics', - }, - { - label: 'Delegate Task', - command: 'ccs glm -p "your task"', - description: 'Delegate to GLM profile', - }, -]; - -interface QuickCommandsProps { - snippets?: CommandSnippet[]; -} - -export function QuickCommands({ snippets = defaultSnippets }: QuickCommandsProps) { - const [copiedIndex, setCopiedIndex] = useState(null); - - const copyToClipboard = async (text: string, index: number) => { - await navigator.clipboard.writeText(text); - setCopiedIndex(index); - setTimeout(() => setCopiedIndex(null), 2000); - }; - - return ( - - - - - Quick Commands - - - -
- {snippets.map((snippet, index) => ( -
-
-

{snippet.label}

- - {snippet.command} - -
- -
- ))} -
-
-
- ); -} diff --git a/ui/src/components/settings-dialog.tsx b/ui/src/components/settings-dialog.tsx deleted file mode 100644 index 624ce907..00000000 --- a/ui/src/components/settings-dialog.tsx +++ /dev/null @@ -1,387 +0,0 @@ -/** - * Settings Dialog Component - * Reusable dialog for editing profile environment variables - * Features: masked inputs for sensitive keys, conflict detection, save/cancel, raw JSON editor - */ - -import { useState, useMemo, useCallback, lazy, Suspense } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { MaskedInput } from '@/components/ui/masked-input'; -import { ConfirmDialog } from '@/components/confirm-dialog'; -import { Save, X, Loader2, Code2 } from 'lucide-react'; -import { toast } from 'sonner'; - -// Lazy load CodeEditor to reduce initial bundle size -const CodeEditor = lazy(() => - import('@/components/code-editor').then((m) => ({ default: m.CodeEditor })) -); - -interface Settings { - env?: Record; -} - -interface SettingsResponse { - profile: string; - settings: Settings; - mtime: number; - path: string; -} - -interface SettingsDialogProps { - open: boolean; - onClose: () => void; - profileName: string | null; -} - -/** - * Inner component that manages local edits state - * Gets unmounted/remounted via key prop when dialog closes/opens - */ -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; - -function SettingsDialogContent({ - profileName, - onClose, -}: { - profileName: string; - onClose: () => void; -}) { - const [localEdits, setLocalEdits] = useState>({}); - const [conflictDialog, setConflictDialog] = useState(false); - const [rawJsonEdits, setRawJsonEdits] = useState(null); - const [activeTab, setActiveTab] = useState('env'); - const queryClient = useQueryClient(); - - // Fetch settings for selected profile - const { data, isLoading, refetch } = useQuery({ - queryKey: ['settings', profileName], - queryFn: () => fetch(`/api/settings/${profileName}/raw`).then((r) => r.json()), - }); - - // Derive raw JSON content: use edits if available, otherwise serialize from data - const settings = data?.settings; - const rawJsonContent = useMemo(() => { - if (rawJsonEdits !== null) { - return rawJsonEdits; - } - if (settings) { - return JSON.stringify(settings, null, 2); - } - return ''; - }, [rawJsonEdits, settings]); - - // Update raw JSON when user edits - const handleRawJsonChange = useCallback((value: string) => { - setRawJsonEdits(value); - }, []); - - // Derive current settings by merging original data with local edits - const currentSettings = useMemo((): Settings | undefined => { - const settings = data?.settings; - if (!settings) return undefined; - return { - ...settings, - env: { - ...settings.env, - ...localEdits, - }, - }; - }, [data?.settings, localEdits]); - - // Check if raw JSON is valid - const isRawJsonValid = useMemo(() => { - try { - JSON.parse(rawJsonContent); - return true; - } catch { - return false; - } - }, [rawJsonContent]); - - // Save mutation - const saveMutation = useMutation({ - mutationFn: async () => { - let settingsToSave: Settings; - - // Determine what to save based on active tab - if (activeTab === 'raw') { - // Parse raw JSON content - try { - settingsToSave = JSON.parse(rawJsonContent); - } catch { - throw new Error('Invalid JSON'); - } - } else { - // Use form-based edits - settingsToSave = { - ...data?.settings, - env: { - ...data?.settings?.env, - ...localEdits, - }, - }; - } - - const res = await fetch(`/api/settings/${profileName}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - settings: settingsToSave, - expectedMtime: data?.mtime, - }), - }); - - if (res.status === 409) { - throw new Error('CONFLICT'); - } - - if (!res.ok) { - throw new Error('Failed to save'); - } - - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['settings', profileName] }); - queryClient.invalidateQueries({ queryKey: ['profiles'] }); - toast.success('Settings saved'); - onClose(); - }, - onError: (error: Error) => { - if (error.message === 'CONFLICT') { - setConflictDialog(true); - } else { - toast.error(error.message); - } - }, - }); - - const handleSave = () => { - saveMutation.mutate(); - }; - - const handleConflictResolve = async (overwrite: boolean) => { - setConflictDialog(false); - if (overwrite) { - // Refetch to get new mtime, then save - await refetch(); - saveMutation.mutate(); - } else { - // Discard local changes and close - onClose(); - } - }; - - const updateEnvValue = (key: string, value: string) => { - setLocalEdits((prev) => ({ - ...prev, - [key]: value, - })); - }; - - const isSensitiveKey = (key: string): boolean => { - // Pattern-based matching for sensitive keys (same as backend) - const sensitivePatterns = [ - /^ANTHROPIC_AUTH_TOKEN$/, // Exact match for Anthropic auth token - /_API_KEY$/, // Keys ending with _API_KEY - /_AUTH_TOKEN$/, // Keys ending with _AUTH_TOKEN - /^API_KEY$/, // Exact match for API_KEY - /^AUTH_TOKEN$/, // Exact match for AUTH_TOKEN - /_SECRET$/, // Keys ending with _SECRET - /^SECRET$/, // Exact match for SECRET - ]; - return sensitivePatterns.some((pattern) => pattern.test(key)); - }; - - return ( - <> - - Edit Profile: {profileName} - - Configure environment variables and settings for this profile. - - - - {isLoading ? ( -
- - Loading settings... -
- ) : ( -
- - - - Environment - - - - Raw JSON - - - General - - - - - - {currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? ( -
- {Object.entries(currentSettings.env).map(([key, value]) => ( -
- - {isSensitiveKey(key) ? ( - updateEnvValue(key, e.target.value)} - className="font-mono text-sm" - /> - ) : ( - updateEnvValue(key, e.target.value)} - className="font-mono text-sm" - /> - )} -
- ))} -
- ) : ( -
-

No environment variables configured.

-

Add variables in your settings.json file.

-
- )} -
-
- - - - - Loading editor... -
- } - > - - - - - - - - Profile Information - Details about this configuration file. - - - {data && ( - <> -
- Path - - {data.path} - -
-
- Last Modified - {new Date(data.mtime).toLocaleString()} -
- - )} -
-
-
- - -
- - -
-
- )} - - handleConflictResolve(true)} - onCancel={() => handleConflictResolve(false)} - /> - - ); -} - -export function SettingsDialog({ open, onClose, profileName }: SettingsDialogProps) { - // Handle dialog open/close state changes - const handleOpenChange = useCallback( - (isOpen: boolean) => { - if (!isOpen) { - onClose(); - } - }, - [onClose] - ); - - return ( - - - {/* Key prop ensures fresh state on each open */} - {open && profileName && ( - - )} - - - ); -} diff --git a/ui/src/components/sponsor-button.tsx b/ui/src/components/sponsor-button.tsx deleted file mode 100644 index 382d9afb..00000000 --- a/ui/src/components/sponsor-button.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Sponsor Button - * - * GitHub Sponsors button for navbar. - * Heart icon with hover animation. - */ - -import { Heart } from 'lucide-react'; -import { cn } from '@/lib/utils'; - -const SPONSOR_URL = 'https://github.com/sponsors/kaitranntt'; - -export function SponsorButton() { - return ( - - - - Sponsor - - - ); -} diff --git a/ui/src/components/stat-card.tsx b/ui/src/components/stat-card.tsx deleted file mode 100644 index 8ef7f37b..00000000 --- a/ui/src/components/stat-card.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { Card, CardContent } from '@/components/ui/card'; -import type { LucideIcon } from 'lucide-react'; -import { cn } from '@/lib/utils'; - -interface StatCardProps { - title: string; - value: number | string; - icon: LucideIcon; - color?: string; - variant?: 'default' | 'success' | 'warning' | 'error' | 'accent'; - subtitle?: string; - onClick?: () => void; -} - -const variantStyles = { - default: { - iconBg: 'bg-muted', - iconColor: 'text-muted-foreground', - borderHover: 'hover:border-primary', - }, - success: { - iconBg: 'bg-green-500/10', - iconColor: 'text-green-600', - borderHover: 'hover:border-green-500/50', - }, - warning: { - iconBg: 'bg-yellow-500/10', - iconColor: 'text-yellow-500', - borderHover: 'hover:border-yellow-500/50', - }, - error: { - iconBg: 'bg-red-500/10', - iconColor: 'text-red-500', - borderHover: 'hover:border-red-500/50', - }, - accent: { - iconBg: 'bg-accent/10', - iconColor: 'text-accent', - borderHover: 'hover:border-accent/50', - }, -}; - -export function StatCard({ - title, - value, - icon: Icon, - color, - variant = 'default', - subtitle, - onClick, -}: StatCardProps) { - const styles = variantStyles[variant]; - const iconColorClass = color || styles.iconColor; - - return ( - - -
- {/* Icon Container with background */} -
- -
- - {/* Content */} -
-

{title}

-

{value}

- {subtitle &&

{subtitle}

} -
-
-
-
- ); -} diff --git a/ui/src/components/theme-provider.tsx b/ui/src/components/theme-provider.tsx deleted file mode 100644 index d113fe46..00000000 --- a/ui/src/components/theme-provider.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useEffect, useState } from 'react'; -import { ThemeProviderContext } from '@/contexts/theme-context'; -import type { Theme } from '@/contexts/theme-context'; - -type ThemeProviderProps = { - children: React.ReactNode; - defaultTheme?: Theme; - storageKey?: string; -}; - -export function ThemeProvider({ - children, - defaultTheme = 'system', - storageKey = 'vite-ui-theme', -}: ThemeProviderProps) { - const [theme, setTheme] = useState( - () => (localStorage.getItem(storageKey) as Theme) || defaultTheme - ); - - // Track system preference separately - // Initialize with window.matchMedia value if available - const [systemIsDark, setSystemIsDark] = useState(() => { - if (typeof window !== 'undefined') { - return window.matchMedia('(prefers-color-scheme: dark)').matches; - } - return false; - }); - - // Listen for system theme changes - useEffect(() => { - const media = window.matchMedia('(prefers-color-scheme: dark)'); - - const listener = (e: MediaQueryListEvent) => { - setSystemIsDark(e.matches); - }; - - media.addEventListener('change', listener); - return () => media.removeEventListener('change', listener); - }, []); - - // Derive effective theme - const isDark = theme === 'dark' || (theme === 'system' && systemIsDark); - - // Update DOM when effective theme changes - useEffect(() => { - const root = window.document.documentElement; - root.classList.remove('light', 'dark'); - root.classList.add(isDark ? 'dark' : 'light'); - }, [isDark]); - - const value = { - theme, - setTheme: (theme: Theme) => { - localStorage.setItem(storageKey, theme); - setTheme(theme); - }, - isDark, - }; - - return {children}; -} diff --git a/ui/src/components/theme-toggle.tsx b/ui/src/components/theme-toggle.tsx deleted file mode 100644 index 619b3a11..00000000 --- a/ui/src/components/theme-toggle.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Moon, Sun } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { useTheme } from '@/hooks/use-theme'; - -export function ThemeToggle() { - const { theme, setTheme } = useTheme(); - - return ( - - ); -} diff --git a/ui/src/components/value-metrics.tsx b/ui/src/components/value-metrics.tsx deleted file mode 100644 index 9975a7b9..00000000 --- a/ui/src/components/value-metrics.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { TrendingUpIcon, TrendingDownIcon, DollarSignIcon, ZapIcon } from 'lucide-react'; - -interface MetricCardProps { - title: string; - value: string | number; - change?: number; - changeLabel?: string; - icon: React.ReactNode; - trend?: 'up' | 'down' | 'neutral'; -} - -function MetricCard({ title, value, change, changeLabel, icon, trend }: MetricCardProps) { - const TrendIcon = trend === 'up' ? TrendingUpIcon : trend === 'down' ? TrendingDownIcon : null; - - return ( - - -
-
- {icon} -

{title}

-
- {trend && TrendIcon && ( - - - {change && `${Math.abs(change)}%`} - - )} -
-
-
{value}
- {changeLabel &&
{changeLabel}
} -
-
-
- ); -} - -export function ValueMetrics() { - // Mock data for demonstration - const metrics = [ - { - title: 'API Cost Saved', - value: '$127.50', - change: 23, - changeLabel: 'vs last month', - icon: , - trend: 'up' as const, - }, - { - title: 'Tokens Saved', - value: '2.4M', - change: 18, - changeLabel: 'through caching', - icon: , - trend: 'up' as const, - }, - { - title: 'Queries Faster', - value: '43%', - change: 12, - changeLabel: 'average speedup', - icon: , - trend: 'up' as const, - }, - { - title: 'Errors Reduced', - value: '-67%', - change: 67, - changeLabel: 'with retry logic', - icon: , - trend: 'down' as const, - }, - ]; - - return ( -
-

Performance Metrics

-
- {metrics.map((metric, index) => ( - - ))} -
- - - - Monthly Summary - - -
-
-
$342.10
-
Total Saved
-
-
-
8.7M
-
Tokens Processed
-
-
-
1,247
-
Queries Handled
-
-
-
99.8%
-
Uptime
-
-
-
-
-
- ); -}