feat(ui): add CLIProxy sync components

- add SyncStatusCard for sync status display
- add SyncDialog for sync preview and execution
- add useCliproxySync hook for API integration
This commit is contained in:
kaitranntt
2026-01-28 11:57:22 -05:00
parent fc23afdfc7
commit 75a4e68f95
5 changed files with 568 additions and 0 deletions
+4
View File
@@ -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';
+6
View File
@@ -0,0 +1,6 @@
/**
* Sync Components Barrel Export
*/
export { SyncStatusCard } from './sync-status-card';
export { SyncDialog } from './sync-dialog';
@@ -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 (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="w-5 h-5" />
Sync Profiles to Remote CLIProxy
</DialogTitle>
<DialogDescription>
Push your CCS API profiles to the remote CLIProxy server.
</DialogDescription>
</DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab} className="mt-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="preview">Preview</TabsTrigger>
<TabsTrigger value="aliases">Model Aliases</TabsTrigger>
</TabsList>
<TabsContent value="preview" className="mt-4">
{previewLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : preview?.count === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No profiles configured to sync.</p>
<p className="text-sm mt-2">Create API profiles first using the Profiles tab.</p>
</div>
) : (
<ScrollArea className="h-[300px] pr-4">
<div className="space-y-3">
{preview?.profiles.map((profile) => (
<div
key={profile.name}
className="flex items-center justify-between p-3 rounded-lg border bg-muted/30"
>
<div>
<div className="font-medium">{profile.name}</div>
{profile.baseUrl && (
<div className="text-xs text-muted-foreground truncate max-w-[300px]">
{profile.baseUrl}
</div>
)}
</div>
<div className="flex items-center gap-2">
{profile.hasAliases && (
<Badge variant="secondary" className="text-xs">
{profile.aliasCount} alias{profile.aliasCount !== 1 ? 'es' : ''}
</Badge>
)}
<Badge variant="outline" className="text-xs">
Ready
</Badge>
</div>
</div>
))}
</div>
</ScrollArea>
)}
<div className="flex items-center justify-between mt-6 pt-4 border-t">
<div className="text-sm text-muted-foreground">
{preview?.count ?? 0} profile{(preview?.count ?? 0) !== 1 ? 's' : ''} to sync
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
onClick={handleSync}
disabled={isSyncing || (preview?.count ?? 0) === 0}
className={cn('gap-2', isSuccess && 'bg-green-600 hover:bg-green-700')}
>
{isSyncing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Syncing...
</>
) : isSuccess ? (
<>
<CheckCircle className="w-4 h-4" />
Synced!
</>
) : (
<>
<Upload className="w-4 h-4" />
Sync Now
</>
)}
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="aliases" className="mt-4">
<ScrollArea className="h-[300px] pr-4">
{!aliasData?.aliases || Object.keys(aliasData.aliases).length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No model aliases configured.</p>
<p className="text-sm mt-2">
Add aliases via CLI:{' '}
<code className="bg-muted px-1 rounded">ccs cliproxy alias add</code>
</p>
</div>
) : (
<div className="space-y-4">
{Object.entries(aliasData.aliases).map(([profileName, aliases]) => (
<div key={profileName} className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">{profileName}</div>
<div className="space-y-1">
{aliases.map((alias) => (
<div
key={`${profileName}-${alias.from}`}
className="flex items-center gap-2 p-2 rounded border bg-muted/30 text-sm"
>
<code className="flex-1 truncate">{alias.from}</code>
<ArrowRight className="w-4 h-4 text-muted-foreground" />
<code className="flex-1 truncate text-right">{alias.to}</code>
</div>
))}
</div>
</div>
))}
</div>
)}
</ScrollArea>
<div className="mt-6 pt-4 border-t">
<div className="flex items-start gap-2 text-xs text-muted-foreground">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<p>
Model aliases map Claude model names to your provider's model names. Manage
aliases via CLI for now. UI editor coming soon.
</p>
</div>
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
@@ -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 (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Upload className="w-4 h-4" />
Remote Sync
</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-center py-6">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</CardContent>
</Card>
);
}
const isConnected = status?.connected ?? false;
const isConfigured = status?.configured ?? false;
return (
<>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Upload className="w-4 h-4" />
Remote Sync
</CardTitle>
<Badge
variant={isConnected ? 'default' : 'secondary'}
className={cn(
'gap-1.5',
isConnected
? 'bg-green-500/20 text-green-600 dark:text-green-400 border-green-500/30'
: !isConfigured
? 'bg-muted text-muted-foreground'
: 'bg-red-500/20 text-red-600 dark:text-red-400 border-red-500/30'
)}
>
{isConnected ? (
<Wifi className="w-3 h-3" />
) : !isConfigured ? (
<WifiOff className="w-3 h-3" />
) : (
<AlertCircle className="w-3 h-3" />
)}
{isConnected ? 'Connected' : !isConfigured ? 'Not Configured' : 'Disconnected'}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isConnected && status?.remoteUrl && (
<div className="text-xs text-muted-foreground">
<span className="font-medium">Remote:</span> {status.remoteUrl}
{status.latencyMs !== undefined && (
<span className="ml-2">({status.latencyMs}ms)</span>
)}
</div>
)}
{!isConfigured && (
<p className="text-xs text-muted-foreground">
Configure remote proxy in Settings to enable profile sync.
</p>
)}
{isConfigured && !isConnected && status?.error && (
<p className="text-xs text-red-500">{status.error}</p>
)}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => setDialogOpen(true)}
disabled={!isConfigured}
>
Configure
</Button>
<Button
variant="default"
size="sm"
className="flex-1 gap-2"
onClick={handleQuickSync}
disabled={!isConnected || isSyncing}
>
{isSyncing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<RefreshCw className="w-3 h-3" />
)}
Sync Now
</Button>
</div>
</CardContent>
</Card>
<SyncDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</>
);
}
+241
View File
@@ -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<string, ModelAlias[]>;
}
/**
* Fetch sync status from API
*/
async function fetchSyncStatus(): Promise<SyncStatus> {
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<SyncPreview> {
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<SyncResult> {
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<AliasesResponse> {
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'] });
},
});
}