diff --git a/ui/src/components/cliproxy/index.ts b/ui/src/components/cliproxy/index.ts index 5c743916..e6b08629 100644 --- a/ui/src/components/cliproxy/index.ts +++ b/ui/src/components/cliproxy/index.ts @@ -27,3 +27,7 @@ export { YamlEditor } from './config/yaml-editor'; export { CredentialHealthList } from './overview/credential-health-list'; export { ModelPreferencesGrid } from './overview/model-preferences-grid'; export { QuickStatsRow } from './overview/quick-stats-row'; + +// Sync components (from subdirectory) +export { SyncStatusCard } from './sync/sync-status-card'; +export { SyncDialog } from './sync/sync-dialog'; diff --git a/ui/src/components/cliproxy/sync/index.ts b/ui/src/components/cliproxy/sync/index.ts new file mode 100644 index 00000000..7ebba405 --- /dev/null +++ b/ui/src/components/cliproxy/sync/index.ts @@ -0,0 +1,6 @@ +/** + * Sync Components Barrel Export + */ + +export { SyncStatusCard } from './sync-status-card'; +export { SyncDialog } from './sync-dialog'; diff --git a/ui/src/components/cliproxy/sync/sync-dialog.tsx b/ui/src/components/cliproxy/sync/sync-dialog.tsx new file mode 100644 index 00000000..87da360d --- /dev/null +++ b/ui/src/components/cliproxy/sync/sync-dialog.tsx @@ -0,0 +1,188 @@ +/** + * Sync Dialog Component + * Dialog for managing sync configuration, preview, and execution + */ + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Loader2, Upload, CheckCircle, AlertCircle, ArrowRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useSyncPreview, useExecuteSync, useSyncAliases } from '@/hooks/use-cliproxy-sync'; + +interface SyncDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function SyncDialog({ open, onOpenChange }: SyncDialogProps) { + const [activeTab, setActiveTab] = useState('preview'); + const { data: preview, isLoading: previewLoading } = useSyncPreview(); + const { data: aliasData } = useSyncAliases(); + const { mutate: executeSync, isPending: isSyncing, isSuccess, reset } = useExecuteSync(); + + const handleSync = () => { + executeSync(undefined, { + onSuccess: () => { + // Keep dialog open to show success + }, + }); + }; + + const handleClose = () => { + reset(); + onOpenChange(false); + }; + + return ( + + + + + + Sync Profiles to Remote CLIProxy + + + Push your CCS API profiles to the remote CLIProxy server. + + + + + + Preview + Model Aliases + + + + {previewLoading ? ( +
+ +
+ ) : preview?.count === 0 ? ( +
+

No profiles configured to sync.

+

Create API profiles first using the Profiles tab.

+
+ ) : ( + +
+ {preview?.profiles.map((profile) => ( +
+
+
{profile.name}
+ {profile.baseUrl && ( +
+ {profile.baseUrl} +
+ )} +
+
+ {profile.hasAliases && ( + + {profile.aliasCount} alias{profile.aliasCount !== 1 ? 'es' : ''} + + )} + + Ready + +
+
+ ))} +
+
+ )} + +
+
+ {preview?.count ?? 0} profile{(preview?.count ?? 0) !== 1 ? 's' : ''} to sync +
+
+ + +
+
+
+ + + + {!aliasData?.aliases || Object.keys(aliasData.aliases).length === 0 ? ( +
+

No model aliases configured.

+

+ Add aliases via CLI:{' '} + ccs cliproxy alias add +

+
+ ) : ( +
+ {Object.entries(aliasData.aliases).map(([profileName, aliases]) => ( +
+
{profileName}
+
+ {aliases.map((alias) => ( +
+ {alias.from} + + {alias.to} +
+ ))} +
+
+ ))} +
+ )} +
+ +
+
+ +

+ Model aliases map Claude model names to your provider's model names. Manage + aliases via CLI for now. UI editor coming soon. +

+
+
+
+
+
+
+ ); +} diff --git a/ui/src/components/cliproxy/sync/sync-status-card.tsx b/ui/src/components/cliproxy/sync/sync-status-card.tsx new file mode 100644 index 00000000..028dc0ed --- /dev/null +++ b/ui/src/components/cliproxy/sync/sync-status-card.tsx @@ -0,0 +1,129 @@ +/** + * Sync Status Card Component + * Shows remote CLIProxy connection status and sync controls + */ + +import { useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Loader2, RefreshCw, Upload, Wifi, WifiOff, AlertCircle } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { SyncDialog } from './sync-dialog'; +import { useSyncStatus, useExecuteSync } from '@/hooks/use-cliproxy-sync'; + +export function SyncStatusCard() { + const [dialogOpen, setDialogOpen] = useState(false); + const { data: status, isLoading, refetch } = useSyncStatus(); + const { mutate: executeSync, isPending: isSyncing } = useExecuteSync(); + + const handleQuickSync = () => { + executeSync(undefined, { + onSuccess: () => { + refetch(); + }, + }); + }; + + if (isLoading) { + return ( + + + + + Remote Sync + + + + + + + ); + } + + const isConnected = status?.connected ?? false; + const isConfigured = status?.configured ?? false; + + return ( + <> + + +
+ + + Remote Sync + + + {isConnected ? ( + + ) : !isConfigured ? ( + + ) : ( + + )} + {isConnected ? 'Connected' : !isConfigured ? 'Not Configured' : 'Disconnected'} + +
+
+ + {isConnected && status?.remoteUrl && ( +
+ Remote: {status.remoteUrl} + {status.latencyMs !== undefined && ( + ({status.latencyMs}ms) + )} +
+ )} + + {!isConfigured && ( +

+ Configure remote proxy in Settings to enable profile sync. +

+ )} + + {isConfigured && !isConnected && status?.error && ( +

{status.error}

+ )} + +
+ + +
+
+
+ + + + ); +} diff --git a/ui/src/hooks/use-cliproxy-sync.ts b/ui/src/hooks/use-cliproxy-sync.ts new file mode 100644 index 00000000..f5ddd517 --- /dev/null +++ b/ui/src/hooks/use-cliproxy-sync.ts @@ -0,0 +1,241 @@ +/** + * React Query hooks for CLIProxy sync functionality + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +/** Sync status response */ +export interface SyncStatus { + connected: boolean; + configured: boolean; + remoteUrl?: string; + latencyMs?: number; + version?: string; + error?: string; + errorCode?: string; +} + +/** Sync preview item */ +export interface SyncPreviewItem { + name: string; + baseUrl?: string; + hasAliases: boolean; + aliasCount: number; +} + +/** Masked payload item for preview */ +interface MaskedPayloadItem { + 'api-key': string; + 'base-url'?: string; + prefix?: string; + models?: { name: string; alias: string }[]; +} + +/** Sync preview response */ +export interface SyncPreview { + profiles: SyncPreviewItem[]; + payload: MaskedPayloadItem[]; + count: number; +} + +/** Sync result response */ +export interface SyncResult { + success: boolean; + syncedCount?: number; + remoteUrl?: string; + profiles?: string[]; + error?: string; + errorCode?: string; + message?: string; +} + +/** Model alias */ +export interface ModelAlias { + from: string; + to: string; +} + +/** Aliases response */ +export interface AliasesResponse { + aliases: Record; +} + +/** + * Fetch sync status from API + */ +async function fetchSyncStatus(): Promise { + const response = await fetch('/api/cliproxy/sync/status'); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to fetch sync status'); + } + return response.json(); +} + +/** + * Fetch sync preview from API + */ +async function fetchSyncPreview(): Promise { + const response = await fetch('/api/cliproxy/sync/preview'); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to fetch sync preview'); + } + return response.json(); +} + +/** + * Execute sync to remote CLIProxy + */ +async function executeSync(): Promise { + const response = await fetch('/api/cliproxy/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Sync failed'); + } + + return data; +} + +/** + * Fetch model aliases from API + */ +async function fetchAliases(): Promise { + const response = await fetch('/api/cliproxy/sync/aliases'); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to fetch aliases'); + } + return response.json(); +} + +/** + * Add a model alias + */ +async function addAlias(params: { + profile: string; + from: string; + to: string; +}): Promise<{ success: boolean }> { + const response = await fetch('/api/cliproxy/sync/aliases', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to add alias'); + } + + return response.json(); +} + +/** + * Remove a model alias + */ +async function removeAlias(params: { + profile: string; + from: string; +}): Promise<{ success: boolean }> { + const response = await fetch('/api/cliproxy/sync/aliases', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to remove alias'); + } + + return response.json(); +} + +/** + * Hook to get sync status + */ +export function useSyncStatus() { + return useQuery({ + queryKey: ['cliproxy-sync-status'], + queryFn: fetchSyncStatus, + refetchInterval: 30000, // Check every 30 seconds + retry: 1, + staleTime: 10000, + }); +} + +/** + * Hook to get sync preview + */ +export function useSyncPreview() { + return useQuery({ + queryKey: ['cliproxy-sync-preview'], + queryFn: fetchSyncPreview, + staleTime: 5000, + retry: 1, + }); +} + +/** + * Hook to execute sync + */ +export function useExecuteSync() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: executeSync, + onSuccess: () => { + // Invalidate sync-related queries after successful sync + queryClient.invalidateQueries({ queryKey: ['cliproxy-sync-status'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-sync-preview'] }); + }, + }); +} + +/** + * Hook to get model aliases + */ +export function useSyncAliases() { + return useQuery({ + queryKey: ['cliproxy-sync-aliases'], + queryFn: fetchAliases, + staleTime: 30000, + retry: 1, + }); +} + +/** + * Hook to add a model alias + */ +export function useAddAlias() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: addAlias, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['cliproxy-sync-aliases'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-sync-preview'] }); + }, + }); +} + +/** + * Hook to remove a model alias + */ +export function useRemoveAlias() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: removeAlias, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['cliproxy-sync-aliases'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-sync-preview'] }); + }, + }); +}