diff --git a/ccw/frontend/src/components/api-settings/CacheSettings.tsx b/ccw/frontend/src/components/api-settings/CacheSettings.tsx new file mode 100644 index 00000000..ce1cf6fa --- /dev/null +++ b/ccw/frontend/src/components/api-settings/CacheSettings.tsx @@ -0,0 +1,301 @@ +// ======================================== +// Cache Settings Component +// ======================================== +// Global cache configuration form with statistics display + +import { useState, useEffect } from 'react'; +import { useIntl } from 'react-intl'; +import { + Database, + Trash2, + Save, + HardDrive, + FileText, +} from 'lucide-react'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Label } from '@/components/ui/Label'; +import { Switch } from '@/components/ui/Switch'; +import { Progress } from '@/components/ui/Progress'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/AlertDialog'; +import { + useCacheStats, + useUpdateCacheSettings, + useClearCache, +} from '@/hooks/useApiSettings'; +import { useNotifications } from '@/hooks/useNotifications'; +import type { GlobalCacheSettings } from '@/lib/api'; +import { cn } from '@/lib/utils'; + +// ========== Types ========== + +export interface CacheSettingsProps { + className?: string; +} + +// ========== Helper Components ========== + +interface CacheUsageIndicatorProps { + used: number; + total: number; +} + +function CacheUsageIndicator({ used, total }: CacheUsageIndicatorProps) { + const { formatMessage } = useIntl(); + const percentage = total > 0 ? Math.round((used / total) * 100) : 0; + + return ( +
+
+ + {formatMessage({ id: 'apiSettings.cache.settings.cacheUsage' })} + + + {percentage}% + +
+ +
+ + {formatMessage({ id: 'apiSettings.cache.settings.used' })} {(used / 1024 / 1024).toFixed(2)} MB + + + {formatMessage({ id: 'apiSettings.cache.settings.total' })} {(total / 1024 / 1024).toFixed(2)} MB + +
+
+ ); +} + +interface StatCardProps { + icon: React.ReactNode; + label: string; + value: string | number; + className?: string; +} + +function StatCard({ icon, label, value, className }: StatCardProps) { + return ( + +
+
+ {icon} +
+
+

{label}

+

{value}

+
+
+
+ ); +} + +// ========== Main Component ========== + +export function CacheSettings({ className }: CacheSettingsProps) { + const { formatMessage } = useIntl(); + const { success, error } = useNotifications(); + + // Queries and mutations + const { stats, refetch } = useCacheStats(); + const { updateCacheSettings, isUpdating } = useUpdateCacheSettings(); + const { clearCache, isClearing } = useClearCache(); + + // Form state - initialize with defaults, will update from API if available + const [settings, setSettings] = useState>({ + enabled: true, + cacheDir: '', + maxTotalSizeMB: 1024, + }); + + const [showClearDialog, setShowClearDialog] = useState(false); + + // Update local state when stats load (if API returns settings) + useEffect(() => { + if (stats) { + // CacheStats has totalSize, maxSize, entries - settings might need separate fetch + // For now, keep local state as the source of truth for settings + } + }, [stats]); + + // Handle save + const handleSave = async () => { + try { + await updateCacheSettings(settings); + success( + formatMessage({ id: 'apiSettings.cache.messages.cacheSettingsUpdated' }) + ); + await refetch(); + } catch (err) { + error( + formatMessage({ id: 'common.error' }), + err instanceof Error ? err.message : formatMessage({ id: 'common.error' }) + ); + } + }; + + // Handle clear cache + const handleClearCache = async () => { + try { + await clearCache(); + setShowClearDialog(false); + success( + formatMessage({ id: 'apiSettings.cache.messages.cacheCleared' }) + ); + await refetch(); + } catch (err) { + error( + formatMessage({ id: 'common.error' }), + err instanceof Error ? err.message : formatMessage({ id: 'common.error' }) + ); + } + }; + + return ( +
+ {/* Statistics Cards */} +
+ } + label={formatMessage({ id: 'apiSettings.cache.settings.cacheEntries' })} + value={stats?.entries ?? 0} + /> + } + label={formatMessage({ id: 'apiSettings.cache.settings.cacheSize' })} + value={`${((stats?.totalSize ?? 0) / 1024 / 1024).toFixed(2)} MB`} + /> + } + label={formatMessage({ id: 'apiSettings.cache.settings.maxSize' })} + value={`${((stats?.maxSize ?? 0) / 1024 / 1024).toFixed(2)} MB`} + /> +
+ + {/* Cache Usage Progress */} + {stats && ( + + + + )} + + {/* Settings Form */} + +

+ {formatMessage({ id: 'apiSettings.cache.settings.title' })} +

+ +
+ {/* Enable Global Caching */} +
+
+ +
+ + setSettings({ ...settings, enabled: checked }) + } + disabled={isUpdating} + /> +
+ + {/* Cache Directory */} +
+ + + setSettings({ ...settings, cacheDir: e.target.value }) + } + disabled={isUpdating} + placeholder="/path/to/cache" + /> +
+ + {/* Max Total Size */} +
+ + + setSettings({ + ...settings, + maxTotalSizeMB: parseInt(e.target.value) || 1024, + }) + } + disabled={isUpdating} + /> +
+ + {/* Actions */} +
+ + + +
+
+
+ + {/* Clear Cache Confirmation Dialog */} + + + + + {formatMessage({ id: 'apiSettings.cache.settings.actions.clearCache' })} + + + {formatMessage({ + id: 'apiSettings.cache.settings.confirmClearCache', + })} + + + + + {formatMessage({ id: 'common.cancel' })} + + + {isClearing + ? formatMessage({ id: 'common.loading' }) + : formatMessage({ id: 'common.confirm' })} + + + + +
+ ); +} diff --git a/ccw/frontend/src/components/api-settings/CliSettingsList.tsx b/ccw/frontend/src/components/api-settings/CliSettingsList.tsx new file mode 100644 index 00000000..d83046fb --- /dev/null +++ b/ccw/frontend/src/components/api-settings/CliSettingsList.tsx @@ -0,0 +1,321 @@ +// ======================================== +// CLI Settings List Component +// ======================================== +// Display CLI settings as cards with search, filter, and actions + +import { useState, useMemo } from 'react'; +import { useIntl } from 'react-intl'; +import { + Search, + Plus, + Edit, + Trash2, + Settings, + CheckCircle2, + XCircle, + MoreVertical, + Link as LinkIcon, +} from 'lucide-react'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Badge } from '@/components/ui/Badge'; +import { Switch } from '@/components/ui/Switch'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from '@/components/ui/Dropdown'; +import { + useCliSettings, + useDeleteCliSettings, + useToggleCliSettings, +} from '@/hooks/useApiSettings'; +import { useNotifications } from '@/hooks/useNotifications'; +import type { CliSettingsEndpoint } from '@/lib/api'; +import { cn } from '@/lib/utils'; + +// ========== Types ========== + +export interface CliSettingsListProps { + onAddCliSettings: () => void; + onEditCliSettings: (endpointId: string) => void; +} + +// ========== Helper Components ========== + +interface CliSettingsCardProps { + cliSettings: CliSettingsEndpoint; + onEdit: () => void; + onDelete: () => void; + onToggleEnabled: (enabled: boolean) => void; + isDeleting: boolean; + isToggling: boolean; +} + +function CliSettingsCard({ + cliSettings, + onEdit, + onDelete, + onToggleEnabled, + isDeleting, + isToggling, +}: CliSettingsCardProps) { + const { formatMessage } = useIntl(); + + // Determine mode based on settings + const isProviderBased = Boolean( + cliSettings.settings.env.ANTHROPIC_BASE_URL && + !cliSettings.settings.env.ANTHROPIC_BASE_URL.includes('api.anthropic.com') + ); + + const getModeBadge = () => { + if (isProviderBased) { + return ( + + {formatMessage({ id: 'apiSettings.cliSettings.providerBased' })} + + ); + } + return ( + + {formatMessage({ id: 'apiSettings.cliSettings.direct' })} + + ); + }; + + const getStatusBadge = () => { + if (!cliSettings.enabled) { + return {formatMessage({ id: 'apiSettings.common.disabled' })}; + } + return {formatMessage({ id: 'apiSettings.common.enabled' })}; + }; + + return ( + +
+ {/* Left: CLI Settings Info */} +
+
+

{cliSettings.name}

+ {getStatusBadge()} + {getModeBadge()} +
+ {cliSettings.description && ( +

{cliSettings.description}

+ )} +
+ + + {cliSettings.settings.model || 'sonnet'} + + {cliSettings.settings.env.ANTHROPIC_BASE_URL && ( + + + {cliSettings.settings.env.ANTHROPIC_BASE_URL} + + )} + {cliSettings.settings.includeCoAuthoredBy !== undefined && ( + + Co-authored: {cliSettings.settings.includeCoAuthoredBy ? 'Yes' : 'No'} + + )} +
+
+ + {/* Right: Actions */} +
+ + + + + + + + + {formatMessage({ id: 'apiSettings.cliSettings.actions.edit' })} + + + + + {formatMessage({ id: 'apiSettings.cliSettings.actions.delete' })} + + + +
+
+
+ ); +} + +// ========== Main Component ========== + +export function CliSettingsList({ + onAddCliSettings, + onEditCliSettings, +}: CliSettingsListProps) { + const { formatMessage } = useIntl(); + const { showNotification } = useNotifications(); + const [searchQuery, setSearchQuery] = useState(''); + + const { + cliSettings, + totalCount, + enabledCount, + providerBasedCount, + directCount, + isLoading, + refetch, + } = useCliSettings(); + + const { deleteCliSettings, isDeleting } = useDeleteCliSettings(); + const { toggleCliSettings, isToggling } = useToggleCliSettings(); + + // Filter settings by search query + const filteredSettings = useMemo(() => { + if (!searchQuery) return cliSettings; + const query = searchQuery.toLowerCase(); + return cliSettings.filter( + (s) => + s.name.toLowerCase().includes(query) || + s.description?.toLowerCase().includes(query) || + s.id.toLowerCase().includes(query) + ); + }, [cliSettings, searchQuery]); + + // Handlers + const handleDelete = async (endpointId: string) => { + const settings = cliSettings.find((s) => s.id === endpointId); + if (!settings) return; + + const confirmMessage = formatMessage( + { id: 'apiSettings.cliSettings.deleteConfirm' }, + { name: settings.name } + ); + + if (confirm(confirmMessage)) { + try { + await deleteCliSettings(endpointId); + } catch (error) { + showNotification('error', formatMessage({ id: 'apiSettings.cliSettings.deleteError' })); + } + } + }; + + const handleToggleEnabled = async (endpointId: string, enabled: boolean) => { + try { + await toggleCliSettings(endpointId, enabled); + } catch (error) { + showNotification('error', formatMessage({ id: 'apiSettings.cliSettings.toggleError' })); + } + }; + + return ( +
+ {/* Stats Cards */} +
+ +
+ + {totalCount} +
+

+ {formatMessage({ id: 'apiSettings.cliSettings.stats.total' })} +

+
+ +
+ + {enabledCount} +
+

+ {formatMessage({ id: 'apiSettings.cliSettings.stats.enabled' })} +

+
+ +
+ + {providerBasedCount} +
+

+ {formatMessage({ id: 'apiSettings.cliSettings.providerBased' })} +

+
+ +
+ + {directCount} +
+

+ {formatMessage({ id: 'apiSettings.cliSettings.direct' })} +

+
+
+ + {/* Search and Actions */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+
+ + +
+
+ + {/* CLI Settings Cards */} + {isLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : filteredSettings.length === 0 ? ( + + +

+ {formatMessage({ id: 'apiSettings.cliSettings.emptyState.title' })} +

+

+ {formatMessage({ id: 'apiSettings.cliSettings.emptyState.message' })} +

+
+ ) : ( +
+ {filteredSettings.map((settings) => ( + onEditCliSettings(settings.id)} + onDelete={() => handleDelete(settings.id)} + onToggleEnabled={(enabled) => handleToggleEnabled(settings.id, enabled)} + isDeleting={isDeleting} + isToggling={isToggling} + /> + ))} +
+ )} +
+ ); +} + +export default CliSettingsList; diff --git a/ccw/frontend/src/components/api-settings/CliSettingsModal.tsx b/ccw/frontend/src/components/api-settings/CliSettingsModal.tsx new file mode 100644 index 00000000..6750a3c0 --- /dev/null +++ b/ccw/frontend/src/components/api-settings/CliSettingsModal.tsx @@ -0,0 +1,408 @@ +// ======================================== +// CLI Settings Modal Component +// ======================================== +// Add/Edit CLI settings modal with provider-based and direct modes + +import { useState, useEffect } from 'react'; +import { useIntl } from 'react-intl'; +import { Check, Eye, EyeOff } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + 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 { Textarea } from '@/components/ui/Textarea'; +import { Switch } from '@/components/ui/Switch'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs'; +import { useCreateCliSettings, useUpdateCliSettings, useProviders } from '@/hooks/useApiSettings'; +import { useNotifications } from '@/hooks/useNotifications'; +import type { CliSettingsEndpoint } from '@/lib/api'; + +// ========== Types ========== + +export interface CliSettingsModalProps { + open: boolean; + onClose: () => void; + cliSettings?: CliSettingsEndpoint | null; +} + +type ModeType = 'provider-based' | 'direct'; + +// ========== Main Component ========== + +export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModalProps) { + const { formatMessage } = useIntl(); + const { showNotification } = useNotifications(); + const isEditing = !!cliSettings; + + // Mutations + const { createCliSettings, isCreating } = useCreateCliSettings(); + const { updateCliSettings, isUpdating } = useUpdateCliSettings(); + + // Get providers for provider-based mode + const { providers } = useProviders(); + + // Form state + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [enabled, setEnabled] = useState(true); + const [mode, setMode] = useState('direct'); + + // Provider-based mode state + const [providerId, setProviderId] = useState(''); + const [model, setModel] = useState('sonnet'); + const [includeCoAuthoredBy, setIncludeCoAuthoredBy] = useState(false); + + // Direct mode state + const [authToken, setAuthToken] = useState(''); + const [baseUrl, setBaseUrl] = useState(''); + const [showToken, setShowToken] = useState(false); + + // Validation errors + const [errors, setErrors] = useState>({}); + + // Initialize form from cliSettings + useEffect(() => { + if (cliSettings) { + setName(cliSettings.name); + setDescription(cliSettings.description || ''); + setEnabled(cliSettings.enabled); + setModel(cliSettings.settings.model || 'sonnet'); + setIncludeCoAuthoredBy(cliSettings.settings.includeCoAuthoredBy || false); + + // Determine mode based on settings + const hasCustomBaseUrl = Boolean( + cliSettings.settings.env.ANTHROPIC_BASE_URL && + !cliSettings.settings.env.ANTHROPIC_BASE_URL.includes('api.anthropic.com') + ); + + if (hasCustomBaseUrl) { + setMode('direct'); + setBaseUrl(cliSettings.settings.env.ANTHROPIC_BASE_URL || ''); + setAuthToken(cliSettings.settings.env.ANTHROPIC_AUTH_TOKEN || ''); + } else { + setMode('provider-based'); + // Try to find matching provider + const matchingProvider = providers.find((p) => p.apiBase === cliSettings.settings.env.ANTHROPIC_BASE_URL); + if (matchingProvider) { + setProviderId(matchingProvider.id); + } + } + } else { + // Reset form for new CLI settings + setName(''); + setDescription(''); + setEnabled(true); + setMode('direct'); + setProviderId(''); + setModel('sonnet'); + setIncludeCoAuthoredBy(false); + setAuthToken(''); + setBaseUrl(''); + setErrors({}); + } + }, [cliSettings, open, providers]); + + // Validate form + const validateForm = (): boolean => { + const newErrors: Record = {}; + + if (!name.trim()) { + newErrors.name = formatMessage({ id: 'apiSettings.validation.nameRequired' }); + } + + if (mode === 'provider-based') { + if (!providerId) { + newErrors.providerId = formatMessage({ id: 'apiSettings.cliSettings.validation.providerRequired' }); + } + } else { + // Direct mode + if (!authToken.trim() && !baseUrl.trim()) { + newErrors.direct = formatMessage({ id: 'apiSettings.cliSettings.validation.authOrBaseUrlRequired' }); + } + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + // Handle save + const handleSave = async () => { + if (!validateForm()) return; + + try { + // Build settings object based on mode + const env: Record = { + DISABLE_AUTOUPDATER: '1', + }; + + if (mode === 'provider-based') { + // Provider-based mode: get settings from selected provider + const provider = providers.find((p) => p.id === providerId); + if (provider) { + if (provider.apiBase) { + env.ANTHROPIC_BASE_URL = provider.apiBase; + } + if (provider.apiKey) { + env.ANTHROPIC_AUTH_TOKEN = provider.apiKey; + } + } + } else { + // Direct mode: use manual input + if (authToken.trim()) { + env.ANTHROPIC_AUTH_TOKEN = authToken.trim(); + } + if (baseUrl.trim()) { + env.ANTHROPIC_BASE_URL = baseUrl.trim(); + } + } + + const request = { + id: cliSettings?.id, + name: name.trim(), + description: description.trim() || undefined, + enabled, + settings: { + env, + model, + includeCoAuthoredBy, + }, + }; + + if (isEditing && cliSettings) { + await updateCliSettings(cliSettings.id, request); + } else { + await createCliSettings(request); + } + + onClose(); + } catch (error) { + showNotification('error', formatMessage({ id: 'apiSettings.cliSettings.saveError' })); + } + }; + + // Get selected provider info + const selectedProvider = providers.find((p) => p.id === providerId); + + return ( + + + + + {isEditing + ? formatMessage({ id: 'apiSettings.cliSettings.actions.edit' }) + : formatMessage({ id: 'apiSettings.cliSettings.actions.add' })} + + + {formatMessage({ id: 'apiSettings.cliSettings.modalDescription' })} + + + +
+ {/* Common Fields */} +
+
+ + setName(e.target.value)} + placeholder={formatMessage({ id: 'apiSettings.cliSettings.namePlaceholder' })} + className={errors.name ? 'border-destructive' : ''} + /> + {errors.name &&

{errors.name}

} +
+ +
+ +