diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index 1231c2b4..59e8f10c 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -103,8 +103,8 @@ export { } from './auth-handler'; // Stats fetcher -export type { ClipproxyStats } from './stats-fetcher'; -export { fetchClipproxyStats, isClipproxyRunning } from './stats-fetcher'; +export type { CliproxyStats } from './stats-fetcher'; +export { fetchCliproxyStats, isCliproxyRunning } from './stats-fetcher'; // OpenAI compatibility layer export type { OpenAICompatProvider, OpenAICompatModel } from './openai-compat-manager'; diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts index 2ba44ca5..3e9cdf15 100644 --- a/src/cliproxy/stats-fetcher.ts +++ b/src/cliproxy/stats-fetcher.ts @@ -8,7 +8,7 @@ import { CCS_INTERNAL_API_KEY, CLIPROXY_DEFAULT_PORT } from './config-generator'; /** Usage statistics from CLIProxyAPI */ -export interface ClipproxyStats { +export interface CliproxyStats { /** Total number of requests processed */ totalRequests: number; /** Token counts */ @@ -59,9 +59,9 @@ interface UsageApiResponse { * @param port CLIProxyAPI port (default: 8317) * @returns Stats object or null if unavailable */ -export async function fetchClipproxyStats( +export async function fetchCliproxyStats( port: number = CLIPROXY_DEFAULT_PORT -): Promise { +): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout @@ -123,7 +123,7 @@ export async function fetchClipproxyStats( * @param port CLIProxyAPI port (default: 8317) * @returns true if proxy is running */ -export async function isClipproxyRunning(port: number = CLIPROXY_DEFAULT_PORT): Promise { +export async function isCliproxyRunning(port: number = CLIPROXY_DEFAULT_PORT): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 1000); // 1s timeout diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index c96a68d3..ec8592ef 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -12,7 +12,7 @@ import { Config, Settings } from '../types/config'; import { expandPath } from '../utils/helpers'; import { runHealthChecks, fixHealthIssue } from './health-service'; import { getAllAuthStatus, getOAuthConfig, initializeAccounts } from '../cliproxy/auth-handler'; -import { fetchClipproxyStats, isClipproxyRunning } from '../cliproxy/stats-fetcher'; +import { fetchCliproxyStats, isCliproxyRunning } from '../cliproxy/stats-fetcher'; import { listOpenAICompatProviders, getOpenAICompatProvider, @@ -1036,12 +1036,12 @@ apiRoutes.get('/files', (_req: Request, res: Response): void => { /** * GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics - * Returns: ClipproxyStats or error if proxy not running + * Returns: CliproxyStats or error if proxy not running */ apiRoutes.get('/cliproxy/stats', async (_req: Request, res: Response): Promise => { try { // Check if proxy is running first - const running = await isClipproxyRunning(); + const running = await isCliproxyRunning(); if (!running) { res.status(503).json({ error: 'CLIProxyAPI not running', @@ -1051,7 +1051,7 @@ apiRoutes.get('/cliproxy/stats', async (_req: Request, res: Response): Promise => { try { - const running = await isClipproxyRunning(); + const running = await isCliproxyRunning(); res.json({ running }); } catch (error) { res.status(500).json({ error: (error as Error).message }); diff --git a/ui/bun.lock b/ui/bun.lock index c677c243..4a0d6dae 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -29,11 +29,14 @@ "react-day-picker": "^9.12.0", "react-dom": "^19.2.0", "react-hook-form": "^7.68.0", + "react-resizable-panels": "^3.0.6", "react-router-dom": "^7.10.1", "react-simple-code-editor": "^0.14.1", + "react-virtuoso": "^4.17.0", "recharts": "^2.12.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", + "yaml": "^2.8.2", "zod": "^4.1.13", }, "devDependencies": { @@ -708,6 +711,8 @@ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + "react-resizable-panels": ["react-resizable-panels@3.0.6", "", { "peerDependencies": { "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew=="], + "react-router": ["react-router@7.10.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw=="], "react-router-dom": ["react-router-dom@7.10.1", "", { "dependencies": { "react-router": "7.10.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw=="], @@ -720,6 +725,8 @@ "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + "react-virtuoso": ["react-virtuoso@4.17.0", "", { "peerDependencies": { "react": ">=16 || >=17 || >= 18 || >= 19", "react-dom": ">=16 || >=17 || >= 18 || >=19" } }, "sha512-od3pi2v13v31uzn5zPXC2u3ouISFCVhjFVFch2VvS2Cx7pWA2F1aJa3XhNTN2F07M3lhfnMnsmGeH+7wZICr7w=="], + "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], @@ -788,6 +795,8 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], diff --git a/ui/package.json b/ui/package.json index 9b90a795..e44e696d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -40,11 +40,14 @@ "react-day-picker": "^9.12.0", "react-dom": "^19.2.0", "react-hook-form": "^7.68.0", + "react-resizable-panels": "^3.0.6", "react-router-dom": "^7.10.1", "react-simple-code-editor": "^0.14.1", + "react-virtuoso": "^4.17.0", "recharts": "^2.12.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", + "yaml": "^2.8.2", "zod": "^4.1.13" }, "devDependencies": { diff --git a/ui/src/components/analytics/cliproxy-stats-card.tsx b/ui/src/components/analytics/cliproxy-stats-card.tsx index 1fbf1135..ba21ec9c 100644 --- a/ui/src/components/analytics/cliproxy-stats-card.tsx +++ b/ui/src/components/analytics/cliproxy-stats-card.tsx @@ -14,15 +14,15 @@ import { Badge } from '@/components/ui/badge'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Server, Zap, Cpu, Coins } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { useClipproxyStats, useClipproxyStatus } from '@/hooks/use-cliproxy-stats'; +import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats'; -interface ClipproxyStatsCardProps { +interface CliproxyStatsCardProps { className?: string; } -export function ClipproxyStatsCard({ className }: ClipproxyStatsCardProps) { - const { data: status, isLoading: statusLoading } = useClipproxyStatus(); - const { data: stats, isLoading: statsLoading, error } = useClipproxyStats(status?.running); +export function CliproxyStatsCard({ className }: CliproxyStatsCardProps) { + const { data: status, isLoading: statusLoading } = useCliproxyStatus(); + const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running); const isLoading = statusLoading || (status?.running && statsLoading); diff --git a/ui/src/components/cliproxy-stats-overview.tsx b/ui/src/components/cliproxy-stats-overview.tsx index 7d814db7..b6ff362c 100644 --- a/ui/src/components/cliproxy-stats-overview.tsx +++ b/ui/src/components/cliproxy-stats-overview.tsx @@ -25,15 +25,15 @@ import { TrendingUp, } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { useClipproxyStats, useClipproxyStatus } from '@/hooks/use-cliproxy-stats'; +import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats'; -interface ClipproxyStatsOverviewProps { +interface CliproxyStatsOverviewProps { className?: string; } -export function ClipproxyStatsOverview({ className }: ClipproxyStatsOverviewProps) { - const { data: status, isLoading: statusLoading } = useClipproxyStatus(); - const { data: stats, isLoading: statsLoading, error } = useClipproxyStats(status?.running); +export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps) { + const { data: status, isLoading: statusLoading } = useCliproxyStatus(); + const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running); const isLoading = statusLoading || (status?.running && statsLoading); diff --git a/ui/src/components/cliproxy/cliproxy-header.tsx b/ui/src/components/cliproxy/cliproxy-header.tsx new file mode 100644 index 00000000..c610c60c --- /dev/null +++ b/ui/src/components/cliproxy/cliproxy-header.tsx @@ -0,0 +1,187 @@ +/** + * CLIProxy Header Component + * Fixed header with OAuth login buttons, status indicator, and refresh + */ + +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { RefreshCw, Loader2 } from 'lucide-react'; +import { useCliproxyAuth } from '@/hooks/use-cliproxy'; +import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow'; +import { cn } from '@/lib/utils'; + +interface LoginButtonProps { + provider: string; + displayName: string; + isAuthenticated: boolean; + accountCount: number; + isAuthenticating: boolean; + onLogin: () => void; +} + +function LoginButton({ + displayName, + isAuthenticated, + accountCount, + isAuthenticating, + onLogin, +}: LoginButtonProps) { + if (isAuthenticating) { + return ( + + ); + } + + if (isAuthenticated) { + return ( + + ); + } + + return ( + + ); +} + +// Helper to format relative time +function formatRelativeTime(date: Date): string { + const diff = Date.now() - date.getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return 'just now'; + if (minutes === 1) return '1m ago'; + return `${minutes}m ago`; +} + +// Hook for relative time display that updates periodically +function useRelativeTime(date: Date | undefined): string | null { + const [text, setText] = useState(() => (date ? formatRelativeTime(date) : null)); + + useEffect(() => { + if (!date) return; + + // Update every 30 seconds via interval only + const interval = setInterval(() => { + setText(formatRelativeTime(date)); + }, 30000); + + return () => clearInterval(interval); + }, [date]); + + // Compute current value on each render if date changes + // This is the pure computation part + const currentText = date ? formatRelativeTime(date) : null; + + // Return the more recent of computed or state-based value + // State value will be updated by interval + return date ? currentText : text; +} + +interface CliproxyHeaderProps { + onRefresh: () => void; + isRefreshing: boolean; + lastUpdated?: Date; + isRunning?: boolean; +} + +export function CliproxyHeader({ + onRefresh, + isRefreshing, + lastUpdated, + isRunning = true, +}: CliproxyHeaderProps) { + const { data: authData } = useCliproxyAuth(); + const { provider: authProvider, isAuthenticating, startAuth } = useCliproxyAuthFlow(); + const lastUpdatedText = useRelativeTime(lastUpdated); + + const providers = [ + { id: 'claude', displayName: 'Claude' }, + { id: 'gemini', displayName: 'Gemini' }, + { id: 'codex', displayName: 'Codex' }, + { id: 'agy', displayName: 'Agy' }, + ]; + + const getProviderStatus = (providerId: string) => { + const status = authData?.authStatus.find((s) => s.provider === providerId); + return { + isAuthenticated: status?.authenticated ?? false, + accountCount: status?.accounts?.length ?? 0, + }; + }; + + return ( +
+ {/* Top row: Title and Login Buttons */} +
+
+

CLIProxy

+

+ Manage OAuth providers and configuration +

+
+ + {/* Login Buttons - Wrap on mobile */} +
+ {providers.map((p) => { + const status = getProviderStatus(p.id); + return ( + startAuth(p.id)} + /> + ); + })} +
+
+ + {/* Bottom row: Status and Refresh */} +
+ + + {isRunning ? 'Running' : 'Offline'} + + + {lastUpdatedText && ( + {lastUpdatedText} + )} + + +
+
+ ); +} diff --git a/ui/src/components/cliproxy/cliproxy-tabs.tsx b/ui/src/components/cliproxy/cliproxy-tabs.tsx new file mode 100644 index 00000000..f7dbf560 --- /dev/null +++ b/ui/src/components/cliproxy/cliproxy-tabs.tsx @@ -0,0 +1,55 @@ +/** + * CLIProxy Tabs Component + * Tab navigation wrapper for Overview, Config, and Logs tabs + */ + +import type { ReactNode } from 'react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { LayoutDashboard, FileCode, ScrollText } from 'lucide-react'; + +export type CliproxyTabValue = 'overview' | 'config' | 'logs'; + +interface CliproxyTabsProps { + activeTab: CliproxyTabValue; + onTabChange: (tab: CliproxyTabValue) => void; + children: { + overview: ReactNode; + config: ReactNode; + logs: ReactNode; + }; +} + +const TAB_CONFIG = [ + { value: 'overview' as const, label: 'Overview', icon: LayoutDashboard }, + { value: 'config' as const, label: 'Config', icon: FileCode }, + { value: 'logs' as const, label: 'Logs', icon: ScrollText }, +]; + +export function CliproxyTabs({ activeTab, onTabChange, children }: CliproxyTabsProps) { + return ( + onTabChange(v as CliproxyTabValue)} + className="w-full" + > + + {TAB_CONFIG.map(({ value, label, icon: Icon }) => ( + + + {label} + + ))} + + + + {children.overview} + + + {children.config} + + + {children.logs} + + + ); +} diff --git a/ui/src/components/cliproxy/config/config-split-view.tsx b/ui/src/components/cliproxy/config/config-split-view.tsx new file mode 100644 index 00000000..e2b195bf --- /dev/null +++ b/ui/src/components/cliproxy/config/config-split-view.tsx @@ -0,0 +1,166 @@ +/** + * Config Split View Container + * Main split layout for Config tab with file tree and YAML editor + */ + +import { useState, useEffect } from 'react'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { Button } from '@/components/ui/button'; +import { Save, RotateCcw, GitCompare, Loader2 } from 'lucide-react'; +import { FileTree } from './file-tree'; +import { buildFileTree } from './file-tree-utils'; +import { YamlEditor, EditorStatusBar } from './yaml-editor'; +import { DiffDialog } from './diff-dialog'; +import { useCliproxyConfig, useCliproxyAuthFile } from '@/hooks/use-cliproxy-config'; + +export function ConfigSplitView() { + const [selectedFile, setSelectedFile] = useState('config.yaml'); + const [showDiff, setShowDiff] = useState(false); + + const { + content, + originalContent, + isDirty, + validation, + isLoading, + authFiles, + updateContent, + resetContent, + saveContent, + isSaving, + } = useCliproxyConfig(); + + // For viewing auth files (read-only) + const [viewingAuthFile, setViewingAuthFile] = useState(null); + const { data: authFileContent } = useCliproxyAuthFile(viewingAuthFile); + + const handleFileSelect = (path: string) => { + if (path === 'config.yaml') { + setSelectedFile(path); + setViewingAuthFile(null); + } else if (path.startsWith('auths/')) { + const fileName = path.replace('auths/', ''); + setSelectedFile(path); + setViewingAuthFile(fileName); + } + }; + + // Build file tree + const fileTree = buildFileTree(isDirty, authFiles); + + // Keyboard shortcut for save + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + if (isDirty && validation.valid) { + saveContent(); + } + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isDirty, validation.valid, saveContent]); + + const isEditingConfig = selectedFile === 'config.yaml'; + const currentContent = isEditingConfig ? content : (authFileContent ?? ''); + const isReadonly = !isEditingConfig; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Toolbar */} +
+
+ {selectedFile} + {isDirty && isEditingConfig && ( + + Modified + + )} +
+ {isEditingConfig && ( +
+ + + +
+ )} +
+ + {/* Split Panels */} + + + + + + + + +
+
+ +
+ +
+
+
+ + {/* Diff Dialog */} + setShowDiff(false)} + original={originalContent} + modified={content} + onConfirmSave={() => { + saveContent(); + setShowDiff(false); + }} + isSaving={isSaving} + /> +
+ ); +} diff --git a/ui/src/components/cliproxy/config/diff-dialog.tsx b/ui/src/components/cliproxy/config/diff-dialog.tsx new file mode 100644 index 00000000..0ffe97db --- /dev/null +++ b/ui/src/components/cliproxy/config/diff-dialog.tsx @@ -0,0 +1,90 @@ +/** + * Diff Dialog Component + * Shows before/after comparison before saving config + */ + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +interface DiffDialogProps { + open: boolean; + onClose: () => void; + original: string; + modified: string; + onConfirmSave: () => void; + isSaving: boolean; +} + +export function DiffDialog({ + open, + onClose, + original, + modified, + onConfirmSave, + isSaving, +}: DiffDialogProps) { + const originalLines = original.split('\n'); + const modifiedLines = modified.split('\n'); + + // Simple line-by-line diff + const renderDiff = () => { + const maxLines = Math.max(originalLines.length, modifiedLines.length); + const rows = []; + + for (let i = 0; i < maxLines; i++) { + const origLine = originalLines[i] ?? ''; + const modLine = modifiedLines[i] ?? ''; + const isChanged = origLine !== modLine; + + rows.push( +
+
{i + 1}
+
+
+ {origLine} +
+
{modLine}
+
+
+ ); + } + + return rows; + }; + + return ( + + + + Review Changes + + +
+
+
Original
+
Modified
+
+ +
{renderDiff()}
+
+
+ + + + + +
+
+ ); +} diff --git a/ui/src/components/cliproxy/config/file-tree-utils.ts b/ui/src/components/cliproxy/config/file-tree-utils.ts new file mode 100644 index 00000000..874dbb00 --- /dev/null +++ b/ui/src/components/cliproxy/config/file-tree-utils.ts @@ -0,0 +1,47 @@ +/** + * File Tree Utilities + * Helper functions for building file tree structure + */ + +export interface FileNode { + name: string; + path: string; + type: 'file' | 'folder'; + children?: FileNode[]; + modified?: boolean; + icon?: 'yaml' | 'json' | 'key'; +} + +// Helper to build file tree from flat list +export function buildFileTree( + configModified: boolean, + authFiles: Array<{ name: string; provider?: string }> +): FileNode[] { + return [ + { + name: 'config', + path: 'config', + type: 'folder', + children: [ + { + name: 'config.yaml', + path: 'config.yaml', + type: 'file', + icon: 'yaml', + modified: configModified, + }, + ], + }, + { + name: 'auths', + path: 'auths', + type: 'folder', + children: authFiles.map((f) => ({ + name: f.name, + path: `auths/${f.name}`, + type: 'file' as const, + icon: 'key' as const, + })), + }, + ]; +} diff --git a/ui/src/components/cliproxy/config/file-tree.tsx b/ui/src/components/cliproxy/config/file-tree.tsx new file mode 100644 index 00000000..6b6c4f07 --- /dev/null +++ b/ui/src/components/cliproxy/config/file-tree.tsx @@ -0,0 +1,125 @@ +/** + * File Tree Component + * Left panel file browser for Config tab + */ + +import { useState } from 'react'; +import { ChevronRight, ChevronDown, FileText, Folder, Key } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import type { FileNode } from './file-tree-utils'; + +interface FileTreeProps { + files: FileNode[]; + selectedFile: string | null; + onSelect: (path: string) => void; +} + +function FileIcon({ type, icon }: { type: 'file' | 'folder'; icon?: string }) { + if (type === 'folder') { + return ; + } + if (icon === 'key') { + return ; + } + return ; +} + +function TreeNode({ + node, + depth, + selectedFile, + onSelect, + expandedFolders, + onToggleFolder, +}: { + node: FileNode; + depth: number; + selectedFile: string | null; + onSelect: (path: string) => void; + expandedFolders: Set; + onToggleFolder: (path: string) => void; +}) { + const isExpanded = expandedFolders.has(node.path); + const isSelected = selectedFile === node.path; + + return ( +
+ + + {node.type === 'folder' && isExpanded && node.children && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ); +} + +export function FileTree({ files, selectedFile, onSelect }: FileTreeProps) { + const [expandedFolders, setExpandedFolders] = useState>(new Set(['config', 'auths'])); + + const toggleFolder = (path: string) => { + setExpandedFolders((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }; + + return ( + +
+ {files.map((node) => ( + + ))} +
+
+ ); +} diff --git a/ui/src/components/cliproxy/config/yaml-editor.tsx b/ui/src/components/cliproxy/config/yaml-editor.tsx new file mode 100644 index 00000000..ff4ce276 --- /dev/null +++ b/ui/src/components/cliproxy/config/yaml-editor.tsx @@ -0,0 +1,129 @@ +/** + * YAML Editor Component + * Right panel YAML editor with syntax highlighting and validation + */ + +import { useState, useCallback } 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 { AlertCircle, CheckCircle2 } from 'lucide-react'; + +interface YamlEditorProps { + value: string; + onChange: (value: string) => void; + readonly?: boolean; + errorLine?: number; + className?: string; +} + +export function YamlEditor({ + value, + onChange, + readonly = false, + errorLine, + className, +}: YamlEditorProps) { + const { isDark } = useTheme(); + const [isFocused, setIsFocused] = useState(false); + + const highlightCode = useCallback( + (code: string) => ( + + {({ tokens, getLineProps, getTokenProps }) => ( + <> + {tokens.map((line, i) => ( +
+ + {i + 1} + + {line.map((token, key) => ( + + ))} +
+ ))} + + )} +
+ ), + [isDark, errorLine] + ); + + return ( +
+ {} : onChange} + highlight={highlightCode} + key={isDark ? 'dark' : 'light'} + padding={12} + disabled={readonly} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + textareaClassName="focus:outline-none font-mono text-sm leading-6" + preClassName="font-mono text-sm leading-6" + style={{ + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + fontSize: '0.875rem', + minHeight: '100%', + }} + /> +
+ ); +} + +interface EditorStatusBarProps { + validation: { valid: boolean; error?: string; line?: number }; + isDirty: boolean; + cursorLine?: number; + cursorCol?: number; +} + +export function EditorStatusBar({ + validation, + isDirty, + cursorLine, + cursorCol, +}: EditorStatusBarProps) { + return ( +
+
+ {validation.valid ? ( + + + Valid YAML + + ) : ( + + + {validation.error} + {validation.line && ` (line ${validation.line})`} + + )} + + {isDirty && Unsaved changes} +
+ + {cursorLine && cursorCol && ( + + Ln {cursorLine}, Col {cursorCol} + + )} +
+ ); +} diff --git a/ui/src/components/cliproxy/overview/credential-health-list.tsx b/ui/src/components/cliproxy/overview/credential-health-list.tsx new file mode 100644 index 00000000..a1833739 --- /dev/null +++ b/ui/src/components/cliproxy/overview/credential-health-list.tsx @@ -0,0 +1,172 @@ +/** + * Credential Health List Component + * Auth status indicators for CLIProxy Overview tab + */ + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { CheckCircle2, AlertCircle, XCircle, MinusCircle, RefreshCw } from 'lucide-react'; +import { useCliproxyAuth } from '@/hooks/use-cliproxy'; +import { cn } from '@/lib/utils'; + +type CredentialStatus = 'ready' | 'warning' | 'error' | 'disabled'; + +interface CredentialRowProps { + name: string; + provider: string; + status: CredentialStatus; + statusMessage: string; + email?: string; + expiresAt?: string; + onRefresh?: () => void; +} + +function CredentialRow({ + name, + provider, + status, + statusMessage, + email, + expiresAt, + onRefresh, +}: CredentialRowProps) { + const statusConfig = { + ready: { + icon: CheckCircle2, + color: 'text-green-600 dark:text-green-400', + bg: 'bg-green-500/10', + }, + warning: { + icon: AlertCircle, + color: 'text-amber-600 dark:text-amber-400', + bg: 'bg-amber-500/10', + }, + error: { + icon: XCircle, + color: 'text-red-600 dark:text-red-400', + bg: 'bg-red-500/10', + }, + disabled: { + icon: MinusCircle, + color: 'text-muted-foreground', + bg: 'bg-muted', + }, + }; + + const config = statusConfig[status]; + const Icon = config.icon; + + const formatExpiry = (date?: string) => { + if (!date) return 'Never'; + const expiry = new Date(date); + const now = new Date(); + const diff = expiry.getTime() - now.getTime(); + if (diff < 0) return 'Expired'; + const hours = Math.floor(diff / 3600000); + if (hours < 1) return 'Soon'; + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; + }; + + return ( +
+
+
+ +
+
+
{email ?? name}
+
{provider}
+
+
+ +
+
+ + {statusMessage} + +
+ Expires: {formatExpiry(expiresAt)} +
+
+ {status === 'warning' && onRefresh && ( + + )} +
+
+ ); +} + +function CredentialHealthSkeleton() { + return ( + + + Credential Health + + +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ + + ); +} + +export function CredentialHealthList() { + const { data: authData, isLoading } = useCliproxyAuth(); + + if (isLoading) { + return ; + } + + // Flatten accounts from all providers + const credentials = + authData?.authStatus.flatMap((status) => + (status.accounts ?? []).map((account) => ({ + name: account.id, + provider: status.provider, + status: (account as { status?: CredentialStatus }).status ?? 'ready', + statusMessage: (account as { statusMessage?: string }).statusMessage ?? 'Ready', + email: account.email, + expiresAt: (account as { expiresAt?: string }).expiresAt, + })) + ) ?? []; + + if (credentials.length === 0) { + return ( + + + Credential Health + + +

+ No credentials configured. Use the login buttons above to authenticate. +

+
+
+ ); + } + + return ( + + + Credential Health + + + {credentials.map((cred, i) => ( + + ))} + + + ); +} diff --git a/ui/src/components/cliproxy/overview/model-preferences-grid.tsx b/ui/src/components/cliproxy/overview/model-preferences-grid.tsx new file mode 100644 index 00000000..42d824d2 --- /dev/null +++ b/ui/src/components/cliproxy/overview/model-preferences-grid.tsx @@ -0,0 +1,140 @@ +/** + * Model Preferences Grid Component + * Model selection per provider for CLIProxy Overview tab + */ + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Loader2 } from 'lucide-react'; +import { useCliproxyModels, useUpdateModel } from '@/hooks/use-cliproxy'; + +interface ProviderModelSelectProps { + provider: string; + displayName: string; + currentModel: string; + availableModels: string[]; + onModelChange: (model: string) => void; + isUpdating: boolean; +} + +function ProviderIcon({ provider }: { provider: string }) { + const colors: Record = { + claude: 'bg-orange-500', + gemini: 'bg-blue-500', + codex: 'bg-green-500', + agy: 'bg-purple-500', + }; + return
; +} + +function ProviderModelSelect({ + provider, + displayName, + currentModel, + availableModels, + onModelChange, + isUpdating, +}: ProviderModelSelectProps) { + return ( +
+
+ + {displayName} +
+
+ {isUpdating && } + +
+
+ ); +} + +function ModelPreferencesSkeleton() { + return ( + + + Model Preferences + + +
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+ + + ); +} + +export function ModelPreferencesGrid() { + const { data: modelsData, isLoading } = useCliproxyModels(); + const updateModel = useUpdateModel(); + + const providers = [ + { id: 'claude', displayName: 'Claude' }, + { id: 'gemini', displayName: 'Gemini' }, + { id: 'codex', displayName: 'Codex' }, + { id: 'agy', displayName: 'Agy' }, + ]; + + if (isLoading) { + return ; + } + + const getProviderModels = (providerId: string) => { + const providerData = modelsData?.providers?.[providerId]; + return { + current: providerData?.currentModel ?? '', + available: providerData?.availableModels ?? [], + }; + }; + + return ( + + + Model Preferences + + +
+ {providers.map((p) => { + const models = getProviderModels(p.id); + return ( + updateModel.mutate({ provider: p.id, model })} + isUpdating={updateModel.isPending && updateModel.variables?.provider === p.id} + /> + ); + })} +
+
+
+ ); +} diff --git a/ui/src/components/cliproxy/overview/quick-stats-row.tsx b/ui/src/components/cliproxy/overview/quick-stats-row.tsx new file mode 100644 index 00000000..7e533d96 --- /dev/null +++ b/ui/src/components/cliproxy/overview/quick-stats-row.tsx @@ -0,0 +1,86 @@ +/** + * Quick Stats Row Component + * Compact stats display for CLIProxy Overview tab + */ + +import type { ReactNode } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Activity, CheckCircle2, Coins, Cpu } from 'lucide-react'; +import { useCliproxyStats } from '@/hooks/use-cliproxy'; + +interface StatCardProps { + icon: ReactNode; + label: string; + value: string | number; +} + +function StatCard({ icon, label, value }: StatCardProps) { + return ( + + +
+
{icon}
+
+

{value}

+

{label}

+
+
+
+
+ ); +} + +function StatsSkeleton() { + return ( +
+ {[1, 2, 3, 4].map((i) => ( + + +
+ + + ))} +
+ ); +} + +export function QuickStatsRow() { + const { data: stats, isLoading } = useCliproxyStats(); + + if (isLoading) { + return ; + } + + const usage = stats?.usage ?? {}; + const totalRequests = (usage.total_requests as number) ?? 0; + const successCount = (usage.success_count as number) ?? 0; + const totalTokens = (usage.total_tokens as number) ?? 0; + + const successRate = totalRequests > 0 ? ((successCount / totalRequests) * 100).toFixed(1) : '0'; + + const tokenDisplay = + totalTokens > 1000 ? `${(totalTokens / 1000).toFixed(1)}K` : String(totalTokens); + + const apis = (usage.apis as Record }>) ?? {}; + const modelCount = Object.keys(apis).reduce((count, api) => { + const apiData = apis[api]; + return count + Object.keys(apiData?.models ?? {}).length; + }, 0); + + return ( +
+ } + label="Total Requests" + value={totalRequests} + /> + } + label="Success Rate" + value={`${successRate}%`} + /> + } label="Total Tokens" value={tokenDisplay} /> + } label="Active Models" value={modelCount} /> +
+ ); +} diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts new file mode 100644 index 00000000..103d98e0 --- /dev/null +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -0,0 +1,188 @@ +/** + * OAuth Auth Flow Hook for CLIProxy + * Manages popup-based OAuth authentication flows + */ + +import { useState, useCallback, useRef, useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +interface AuthFlowState { + provider: string | null; + isAuthenticating: boolean; + error: string | null; +} + +const AUTH_ENDPOINTS: Record = { + claude: '/anthropic-auth-url', + gemini: '/gemini-cli-auth-url', + codex: '/codex-auth-url', + agy: '/antigravity-auth-url', +}; + +const AUTH_TIMEOUT_MS = 300000; // 5 minutes +const POLL_INTERVAL_MS = 500; + +export function useCliproxyAuthFlow() { + const [state, setState] = useState({ + provider: null, + isAuthenticating: false, + error: null, + }); + + const popupRef = useRef(null); + const pollIntervalRef = useRef | null>(null); + const timeoutRef = useRef | null>(null); + const queryClient = useQueryClient(); + + // Cleanup function + const cleanup = useCallback(() => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + if (popupRef.current && !popupRef.current.closed) { + popupRef.current.close(); + } + popupRef.current = null; + }, []); + + // Cleanup on unmount + useEffect(() => { + return () => cleanup(); + }, [cleanup]); + + const startAuth = useCallback( + async (provider: string) => { + const endpoint = AUTH_ENDPOINTS[provider]; + if (!endpoint) { + setState({ + provider: null, + isAuthenticating: false, + error: `Unknown provider: ${provider}`, + }); + return; + } + + setState({ provider, isAuthenticating: true, error: null }); + + try { + // Get auth URL from API + const response = await fetch(`/api/cliproxy${endpoint}?is_webui=true`); + if (!response.ok) { + throw new Error(`Failed to get auth URL: ${response.statusText}`); + } + + const data = await response.json(); + const { url, state: authState } = data; + + if (!url) { + throw new Error('No auth URL returned from server'); + } + + // Open popup + const popup = window.open(url, `${provider}_auth`, 'width=600,height=700,popup=yes'); + + if (!popup) { + throw new Error('Popup blocked. Please allow popups for this site.'); + } + + popupRef.current = popup; + + // Poll for completion + pollIntervalRef.current = setInterval(async () => { + // Check if popup was closed by user + if (popup.closed) { + cleanup(); + // Check final status + try { + const statusRes = await fetch(`/api/cliproxy/get-auth-status?state=${authState}`); + const statusData = await statusRes.json(); + + if (statusData.status === 'ok') { + queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + toast.success(`${provider} authentication successful`); + setState({ provider: null, isAuthenticating: false, error: null }); + } else if (statusData.status === 'error') { + setState({ + provider: null, + isAuthenticating: false, + error: statusData.error || 'Authentication failed', + }); + } else { + // User closed popup before completing + setState({ + provider: null, + isAuthenticating: false, + error: 'Authentication cancelled', + }); + } + } catch { + setState({ + provider: null, + isAuthenticating: false, + error: 'Failed to check auth status', + }); + } + return; + } + + // Poll status while popup is open + try { + const statusRes = await fetch(`/api/cliproxy/get-auth-status?state=${authState}`); + const statusData = await statusRes.json(); + + if (statusData.status === 'ok') { + cleanup(); + queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + toast.success(`${provider} authentication successful`); + setState({ provider: null, isAuthenticating: false, error: null }); + } else if (statusData.status === 'error') { + cleanup(); + setState({ + provider: null, + isAuthenticating: false, + error: statusData.error || 'Authentication failed', + }); + } + // 'wait' status means keep polling + } catch { + // Silently ignore polling errors, will retry + } + }, POLL_INTERVAL_MS); + + // Timeout after 5 minutes + timeoutRef.current = setTimeout(() => { + cleanup(); + toast.error('Authentication timed out'); + setState({ + provider: null, + isAuthenticating: false, + error: 'Authentication timed out', + }); + }, AUTH_TIMEOUT_MS); + } catch (error) { + cleanup(); + const message = error instanceof Error ? error.message : 'Authentication failed'; + toast.error(message); + setState({ provider: null, isAuthenticating: false, error: message }); + } + }, + [cleanup, queryClient] + ); + + const cancelAuth = useCallback(() => { + cleanup(); + setState({ provider: null, isAuthenticating: false, error: null }); + }, [cleanup]); + + return { + ...state, + startAuth, + cancelAuth, + }; +} diff --git a/ui/src/hooks/use-cliproxy-config.ts b/ui/src/hooks/use-cliproxy-config.ts new file mode 100644 index 00000000..23795ced --- /dev/null +++ b/ui/src/hooks/use-cliproxy-config.ts @@ -0,0 +1,169 @@ +/** + * CLIProxy Config Hook + * Manages config.yaml loading, editing, validation, and saving + * + * Uses a controlled editing pattern where edits are tracked separately + * from server state to comply with React Compiler rules. + */ + +import { useCallback, useMemo, useReducer } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { parse as parseYaml } from 'yaml'; +import { api } from '@/lib/api-client'; +import { toast } from 'sonner'; + +interface ValidationResult { + valid: boolean; + error?: string; + line?: number; + column?: number; +} + +type EditAction = + | { type: 'EDIT'; content: string; serverContent: string } + | { type: 'RESET'; serverContent: string } + | { type: 'SAVE_SUCCESS'; content: string }; + +interface EditState { + // Local edit content (empty means using server content) + localContent: string | null; + // Last known server content for dirty detection + lastServerContent: string; +} + +function editReducer(state: EditState, action: EditAction): EditState { + switch (action.type) { + case 'EDIT': + // If editing back to server content, clear local state + if (action.content === action.serverContent) { + return { localContent: null, lastServerContent: action.serverContent }; + } + return { localContent: action.content, lastServerContent: action.serverContent }; + case 'RESET': + return { localContent: null, lastServerContent: action.serverContent }; + case 'SAVE_SUCCESS': + return { localContent: null, lastServerContent: action.content }; + default: + return state; + } +} + +function validateYaml(code: string): ValidationResult { + if (!code.trim()) { + return { valid: true }; + } + + try { + parseYaml(code); + return { valid: true }; + } catch (e: unknown) { + const error = e as { linePos?: Array<{ line: number; col: number }>; message: string }; + const linePos = error.linePos?.[0]; + return { + valid: false, + error: error.message, + line: linePos?.line, + column: linePos?.col, + }; + } +} + +export function useCliproxyConfig() { + const queryClient = useQueryClient(); + + // Fetch config.yaml - server state + const configQuery = useQuery({ + queryKey: ['cliproxy-config-yaml'], + queryFn: () => api.cliproxy.getConfigYaml(), + }); + + // Fetch auth files list + const authFilesQuery = useQuery({ + queryKey: ['cliproxy-auth-files'], + queryFn: () => api.cliproxy.getAuthFiles(), + }); + + // Server content + const serverContent = configQuery.data ?? ''; + + // Edit state - tracks local edits separate from server + const [editState, dispatch] = useReducer(editReducer, { + localContent: null, + lastServerContent: '', + }); + + // Derived: current content (local edit or server) + const content = editState.localContent ?? serverContent; + + // Derived: dirty flag + const isDirty = editState.localContent !== null && editState.localContent !== serverContent; + + // Derived: validation + const validation = useMemo(() => validateYaml(content), [content]); + + // Update content + const updateContent = useCallback( + (newContent: string) => { + dispatch({ type: 'EDIT', content: newContent, serverContent }); + }, + [serverContent] + ); + + // Reset to server content + const resetContent = useCallback(() => { + dispatch({ type: 'RESET', serverContent }); + }, [serverContent]); + + // Save mutation + const saveMutation = useMutation({ + mutationFn: (contentToSave: string) => api.cliproxy.saveConfigYaml(contentToSave), + onSuccess: (_data, variables) => { + dispatch({ type: 'SAVE_SUCCESS', content: variables }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-config-yaml'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy'] }); + toast.success('Configuration saved successfully'); + }, + onError: (error: Error) => { + toast.error(`Failed to save: ${error.message}`); + }, + }); + + // Save handler + const saveContent = useCallback(() => { + if (!validation.valid) { + toast.error('Cannot save invalid YAML'); + return; + } + saveMutation.mutate(content); + }, [content, validation.valid, saveMutation]); + + return { + // State + content, + originalContent: serverContent, + isDirty, + validation, + + // Queries + isLoading: configQuery.isLoading, + isError: configQuery.isError, + authFiles: authFilesQuery.data?.files ?? [], + + // Actions + updateContent, + resetContent, + saveContent, + isSaving: saveMutation.isPending, + + // Refresh + refetch: configQuery.refetch, + }; +} + +export function useCliproxyAuthFile(fileName: string | null) { + return useQuery({ + queryKey: ['cliproxy-auth-file', fileName], + queryFn: () => (fileName ? api.cliproxy.getAuthFile(fileName) : null), + enabled: Boolean(fileName), + }); +} diff --git a/ui/src/hooks/use-cliproxy-logs.ts b/ui/src/hooks/use-cliproxy-logs.ts new file mode 100644 index 00000000..0f6f9ba0 --- /dev/null +++ b/ui/src/hooks/use-cliproxy-logs.ts @@ -0,0 +1,195 @@ +/** + * CLIProxy Logs Hook + * Manages log streaming with buffering and throttling + */ + +import { useState, useCallback, useRef, useEffect } from 'react'; + +export interface LogEntry { + id: string; + timestamp: string; + level: 'info' | 'warn' | 'error' | 'debug'; + message: string; + provider?: string; + requestId?: string; +} + +interface LogsState { + logs: LogEntry[]; + isPaused: boolean; + isConnected: boolean; + lastTimestamp: string | null; + stats: { + total: number; + errors: number; + warnings: number; + }; +} + +const MAX_LOGS = 500; +const FLUSH_INTERVAL = 100; // ms +const POLL_INTERVAL = 1000; // ms + +export function useCliproxyLogs() { + const [state, setState] = useState({ + logs: [], + isPaused: false, + isConnected: false, + lastTimestamp: null, + stats: { total: 0, errors: 0, warnings: 0 }, + }); + + const bufferRef = useRef([]); + const pollTimeoutRef = useRef | null>(null); + const flushIntervalRef = useRef | null>(null); + const isPausedRef = useRef(false); + const lastTimestampRef = useRef(null); + + // Keep refs in sync + useEffect(() => { + isPausedRef.current = state.isPaused; + }, [state.isPaused]); + + useEffect(() => { + lastTimestampRef.current = state.lastTimestamp; + }, [state.lastTimestamp]); + + // Flush buffer to state + const flushBuffer = useCallback(() => { + if (bufferRef.current.length === 0) return; + if (isPausedRef.current) return; + + const newLogs = bufferRef.current; + bufferRef.current = []; + + setState((prev) => { + const combined = [...prev.logs, ...newLogs]; + const trimmed = combined.slice(-MAX_LOGS); + + const errorCount = trimmed.filter((l) => l.level === 'error').length; + const warnCount = trimmed.filter((l) => l.level === 'warn').length; + + return { + ...prev, + logs: trimmed, + stats: { + total: trimmed.length, + errors: errorCount, + warnings: warnCount, + }, + }; + }); + }, []); + + // Poll for new logs + const pollLogs = useCallback(async () => { + if (isPausedRef.current) return; + + try { + const after = lastTimestampRef.current ?? new Date(Date.now() - 60000).toISOString(); + const response = await fetch(`/api/cliproxy/logs?after=${encodeURIComponent(after)}`); + + if (!response.ok) { + throw new Error('Failed to fetch logs'); + } + + const data = await response.json(); + const entries: LogEntry[] = data.logs ?? []; + + if (entries.length > 0) { + bufferRef.current.push(...entries); + const lastEntry = entries[entries.length - 1]; + setState((prev) => ({ + ...prev, + isConnected: true, + lastTimestamp: lastEntry.timestamp, + })); + } else { + setState((prev) => ({ ...prev, isConnected: true })); + } + } catch { + setState((prev) => ({ ...prev, isConnected: false })); + } + + // Schedule next poll + if (!isPausedRef.current) { + pollTimeoutRef.current = setTimeout(pollLogs, POLL_INTERVAL); + } + }, []); + + // Start polling on mount + useEffect(() => { + // Start flush interval + flushIntervalRef.current = setInterval(flushBuffer, FLUSH_INTERVAL); + // Start polling + pollLogs(); + + return () => { + if (flushIntervalRef.current) { + clearInterval(flushIntervalRef.current); + } + if (pollTimeoutRef.current) { + clearTimeout(pollTimeoutRef.current); + } + }; + }, [flushBuffer, pollLogs]); + + // Actions + const pause = useCallback(() => { + setState((prev) => ({ ...prev, isPaused: true })); + if (pollTimeoutRef.current) { + clearTimeout(pollTimeoutRef.current); + pollTimeoutRef.current = null; + } + }, []); + + const resume = useCallback(() => { + setState((prev) => ({ ...prev, isPaused: false })); + pollLogs(); + }, [pollLogs]); + + const clear = useCallback(() => { + bufferRef.current = []; + setState((prev) => ({ + ...prev, + logs: [], + stats: { total: 0, errors: 0, warnings: 0 }, + })); + }, []); + + return { + logs: state.logs, + isPaused: state.isPaused, + isConnected: state.isConnected, + stats: state.stats, + pause, + resume, + clear, + }; +} + +// Filter hook for search and level filtering +export function useLogsFilter(logs: LogEntry[]) { + const [levelFilter, setLevelFilter] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); + + const filteredLogs = logs.filter((log) => { + // Level filter + if (levelFilter !== 'all' && log.level !== levelFilter) { + return false; + } + // Search filter + if (searchQuery && !log.message.toLowerCase().includes(searchQuery.toLowerCase())) { + return false; + } + return true; + }); + + return { + filteredLogs, + levelFilter, + setLevelFilter, + searchQuery, + setSearchQuery, + }; +} diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index 0e852c74..5c2a6ad2 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query'; /** CLIProxy usage statistics */ -export interface ClipproxyStats { +export interface CliproxyStats { totalRequests: number; tokens: { input: number; @@ -20,14 +20,14 @@ export interface ClipproxyStats { } /** CLIProxy running status */ -export interface ClipproxyStatus { +export interface CliproxyStatus { running: boolean; } /** * Fetch CLIProxy stats from API */ -async function fetchClipproxyStats(): Promise { +async function fetchCliproxyStats(): Promise { const response = await fetch('/api/cliproxy/stats'); if (!response.ok) { const error = await response.json(); @@ -39,7 +39,7 @@ async function fetchClipproxyStats(): Promise { /** * Fetch CLIProxy running status */ -async function fetchClipproxyStatus(): Promise { +async function fetchCliproxyStatus(): Promise { const response = await fetch('/api/cliproxy/status'); if (!response.ok) { throw new Error('Failed to fetch status'); @@ -50,10 +50,10 @@ async function fetchClipproxyStatus(): Promise { /** * Hook to get CLIProxy running status */ -export function useClipproxyStatus() { +export function useCliproxyStatus() { return useQuery({ queryKey: ['cliproxy-status'], - queryFn: fetchClipproxyStatus, + queryFn: fetchCliproxyStatus, refetchInterval: 10000, // Check every 10 seconds retry: 1, }); @@ -62,10 +62,10 @@ export function useClipproxyStatus() { /** * Hook to get CLIProxy usage stats */ -export function useClipproxyStats(enabled = true) { +export function useCliproxyStats(enabled = true) { return useQuery({ queryKey: ['cliproxy-stats'], - queryFn: fetchClipproxyStats, + queryFn: fetchCliproxyStats, enabled, refetchInterval: 30000, // Refresh every 30 seconds retry: 1, diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index 4978b583..6e65edf7 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -117,3 +117,35 @@ export function useRemoveAccount() { }, }); } + +// Stats and models hooks for Overview tab +export function useCliproxyStats() { + return useQuery({ + queryKey: ['cliproxy-stats'], + queryFn: () => api.cliproxy.stats(), + refetchInterval: 30000, // Refresh every 30s + }); +} + +export function useCliproxyModels() { + return useQuery({ + queryKey: ['cliproxy-models'], + queryFn: () => api.cliproxy.models(), + }); +} + +export function useUpdateModel() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ provider, model }: { provider: string; model: string }) => + api.cliproxy.updateModel(provider, model), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] }); + toast.success('Model updated'); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 40a6a0c7..1400286e 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -86,6 +86,12 @@ export interface AuthStatus { defaultAccount?: string; } +/** Auth file info for Config tab */ +export interface AuthFile { + name: string; + provider?: string; +} + /** Provider accounts summary */ export type ProviderAccountsMap = Record; @@ -146,6 +152,47 @@ export const api = { body: JSON.stringify(data), }), delete: (name: string) => request(`/cliproxy/${name}`, { method: 'DELETE' }), + + // Stats and models for Overview tab + stats: () => request<{ usage: Record }>('/cliproxy/usage'), + models: () => + request<{ + providers: Record; + }>('/cliproxy/models'), + updateModel: (provider: string, model: string) => + request(`/cliproxy/models/${provider}`, { + method: 'PUT', + body: JSON.stringify({ model }), + }), + + // Config YAML for Config tab + getConfigYaml: async (): Promise => { + const res = await fetch(`${BASE_URL}/cliproxy/config.yaml`); + if (!res.ok) throw new Error('Failed to load config'); + return res.text(); + }, + saveConfigYaml: async (content: string): Promise => { + const res = await fetch(`${BASE_URL}/cliproxy/config.yaml`, { + method: 'PUT', + headers: { 'Content-Type': 'application/yaml' }, + body: content, + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ error: 'Failed to save config' })); + throw new Error(error.error || 'Failed to save config'); + } + }, + + // Auth files for Config tab + getAuthFiles: () => request<{ files: AuthFile[] }>('/cliproxy/auth-files'), + getAuthFile: async (name: string): Promise => { + const res = await fetch( + `${BASE_URL}/cliproxy/auth-files/download?name=${encodeURIComponent(name)}` + ); + if (!res.ok) throw new Error('Failed to load auth file'); + return res.text(); + }, + // Multi-account management accounts: { list: () => request<{ accounts: ProviderAccountsMap }>('/cliproxy/accounts'), diff --git a/ui/src/pages/analytics.tsx b/ui/src/pages/analytics.tsx index e739d5f2..052b93e1 100644 --- a/ui/src/pages/analytics.tsx +++ b/ui/src/pages/analytics.tsx @@ -18,7 +18,7 @@ import { UsageTrendChart } from '@/components/analytics/usage-trend-chart'; import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart'; import { ModelDetailsContent } from '@/components/analytics/model-details-content'; import { SessionStatsCard } from '@/components/analytics/session-stats-card'; -import { ClipproxyStatsCard } from '@/components/analytics/cliproxy-stats-card'; +import { CliproxyStatsCard } from '@/components/analytics/cliproxy-stats-card'; import { UsageInsightsCard } from '@/components/analytics/usage-insights-card'; import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight, Lightbulb } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; @@ -329,7 +329,7 @@ export function AnalyticsPage() { /> {/* CLIProxy Stats - 2/10 width */} - +
{/* Model Details Popover - positioned at cursor */} diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 6b892fad..0765a663 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -1,32 +1,170 @@ /** - * CLIProxy Page - * Phase 03: REST API Routes & CRUD - * Phase 06: Multi-Account Management + * CLIProxy Page - Master-Detail Layout + * Left sidebar: Provider navigation + Quick actions + * Right panel: Provider details, accounts, preferences */ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { Card, CardContent } from '@/components/ui/card'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; +import { Skeleton } from '@/components/ui/skeleton'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { Plus, Check, X, User, ChevronDown, Star, Trash2, Sparkles } from 'lucide-react'; -import { CliproxyTable } from '@/components/cliproxy-table'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Plus, + Check, + X, + User, + Star, + Trash2, + Sparkles, + RefreshCw, + Settings, + FileText, + Terminal, + Zap, + Shield, + Clock, + MoreHorizontal, +} from 'lucide-react'; import { QuickSetupWizard } from '@/components/quick-setup-wizard'; import { AddAccountDialog } from '@/components/add-account-dialog'; -import { ClipproxyStatsOverview } from '@/components/cliproxy-stats-overview'; +import { ConfigSplitView } from '@/components/cliproxy/config/config-split-view'; +import { LogViewer } from '@/components/cliproxy/logs/log-viewer'; import { useCliproxy, useCliproxyAuth, useSetDefaultAccount, useRemoveAccount, + useCliproxyModels, + useUpdateModel, } from '@/hooks/use-cliproxy'; +import { useCliproxyStats } from '@/hooks/use-cliproxy-stats'; import type { OAuthAccount, AuthStatus } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; +type ViewMode = 'overview' | 'config' | 'logs'; + +// Provider icon component +function ProviderIcon({ provider, className }: { provider: string; className?: string }) { + const iconMap: Record = { + gemini: { bg: 'bg-blue-500/10', text: 'text-blue-600', letter: 'G' }, + claude: { bg: 'bg-orange-500/10', text: 'text-orange-600', letter: 'C' }, + codex: { bg: 'bg-green-500/10', text: 'text-green-600', letter: 'X' }, + agy: { bg: 'bg-purple-500/10', text: 'text-purple-600', letter: 'A' }, + }; + const config = iconMap[provider.toLowerCase()] || { + bg: 'bg-gray-500/10', + text: 'text-gray-600', + letter: provider[0]?.toUpperCase() || '?', + }; + + return ( +
+ {config.letter} +
+ ); +} + +// Sidebar provider item +function ProviderSidebarItem({ + status, + isSelected, + onSelect, +}: { + status: AuthStatus; + isSelected: boolean; + onSelect: () => void; +}) { + const accountCount = status.accounts?.length || 0; + + return ( + + ); +} + +// Quick action item in sidebar +function QuickActionItem({ + icon: Icon, + label, + isActive, + onClick, +}: { + icon: React.ElementType; + label: string; + isActive: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +// Account badge with actions function AccountBadge({ account, onSetDefault, @@ -39,122 +177,239 @@ function AccountBadge({ isRemoving: boolean; }) { return ( - - - - - -
-
- {account.email || account.id} -
- {account.lastUsedAt && ( -
Last used: {new Date(account.lastUsedAt).toLocaleDateString()}
- )} -
- {!account.isDefault && ( - - - Set as default - - )} - - - {isRemoving ? 'Removing...' : 'Remove account'} - -
-
- ); -} - -function ProviderRow({ - status, - setDefaultMutation, - removeMutation, - onAddAccount, -}: { - status: AuthStatus; - setDefaultMutation: ReturnType; - removeMutation: ReturnType; - onAddAccount: () => void; -}) { - const accounts = status.accounts || []; - - return ( -
-
+
+
- {status.authenticated ? : } +
-
- {status.displayName} - {accounts.length > 0 && ( - - {accounts.length} +
+ {account.email || account.id} + {account.isDefault && ( + + + Default )}
-
{status.provider}
+ {account.lastUsedAt && ( +
+ + Last used: {new Date(account.lastUsedAt).toLocaleDateString()} +
+ )}
-
- {status.authenticated && accounts.length > 0 ? ( - accounts.map((account) => ( - - setDefaultMutation.mutate({ - provider: status.provider, - accountId: account.id, - }) - } - onRemove={() => - removeMutation.mutate({ - provider: status.provider, - accountId: account.id, - }) - } - isRemoving={removeMutation.isPending} - /> - )) - ) : ( -
- {status.authenticated - ? 'Authenticated (No specific accounts tracked)' - : 'Not authenticated'} + + + + + + {!account.isDefault && ( + + + Set as default + + )} + + + {isRemoving ? 'Removing...' : 'Remove account'} + + + +
+ ); +} + +// Provider detail panel +function ProviderDetailPanel({ + status, + onAddAccount, +}: { + status: AuthStatus; + onAddAccount: () => void; +}) { + const setDefaultMutation = useSetDefaultAccount(); + const removeMutation = useRemoveAccount(); + const { data: modelsData } = useCliproxyModels(); + const updateModelMutation = useUpdateModel(); + const { data: statsData } = useCliproxyStats(); + + const accounts = status.accounts || []; + const providerData = modelsData?.providers?.[status.provider]; + const providerModels = providerData?.availableModels || []; + const currentModel = providerData?.currentModel; + + // Get stats for this provider + const providerRequestCount = useMemo(() => { + if (!statsData?.requestsByProvider) return null; + return statsData.requestsByProvider[status.provider] ?? null; + }, [statsData, status.provider]); + + return ( +
+ {/* Header */} +
+
+ +
+

{status.displayName}

+
+ {status.authenticated ? ( + + + Authenticated + + ) : ( + + + Not connected + + )} + {providerRequestCount !== null && providerRequestCount > 0 && ( + + + {providerRequestCount.toLocaleString()} requests + + )} +
- )} +
+ +
-
- {/* Show Add Account button for all - opens dialog with instructions */} - +
+ )} + + +
+ ); +} + +// Empty state for right panel +function EmptyProviderState({ onSetup }: { onSetup: () => void }) { + return ( +
+
+
+ +
+

CLIProxy Manager

+

+ Manage OAuth authentication for Claude CLI proxy providers. Select a provider from the + sidebar or run the quick setup wizard. +

+
@@ -162,89 +417,160 @@ function ProviderRow({ } export function CliproxyPage() { + const queryClient = useQueryClient(); + const { data: authData, isLoading: authLoading } = useCliproxyAuth(); + const { isFetching } = useCliproxy(); + + const [selectedProvider, setSelectedProvider] = useState(null); + const [viewMode, setViewMode] = useState('overview'); const [wizardOpen, setWizardOpen] = useState(false); const [addAccountProvider, setAddAccountProvider] = useState<{ provider: string; displayName: string; } | null>(null); - const { data, isLoading } = useCliproxy(); - const { data: authData, isLoading: authLoading } = useCliproxyAuth(); - const setDefaultMutation = useSetDefaultAccount(); - const removeMutation = useRemoveAccount(); + + // Auto-select first provider if none selected + const providers = authData?.authStatus || []; + const effectiveProvider = useMemo(() => { + if (selectedProvider && providers.some((p) => p.provider === selectedProvider)) { + return selectedProvider; + } + return providers.length > 0 ? providers[0].provider : null; + }, [selectedProvider, providers]); + + const selectedStatus = providers.find((p) => p.provider === effectiveProvider); + + const handleRefresh = () => { + queryClient.invalidateQueries({ queryKey: ['cliproxy'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + }; return ( -
-
-
-

CLIProxy

-

- Manage OAuth-based provider variants and multi-account configurations -

-
- -
+
+ {/* Left Sidebar */} +
+ {/* Header */} +
+
+
+ +

CLIProxy

+
+ +
- {/* Session Statistics */} - - - {/* Built-in Profiles with Account Management */} -
-
-

Built-in Providers

-

- Manage authentication status and accounts for built-in providers. -

+
- - + {/* Providers List */} + +
+
+ Providers +
{authLoading ? ( -
- Loading authentication status... +
+ {[1, 2, 3, 4].map((i) => ( + + ))}
) : ( -
- {authData?.authStatus.map((status) => ( - + {providers.map((status) => ( + - setAddAccountProvider({ - provider: status.provider, - displayName: status.displayName, - }) - } + isSelected={effectiveProvider === status.provider && viewMode === 'overview'} + onSelect={() => { + setSelectedProvider(status.provider); + setViewMode('overview'); + }} /> ))}
)} - - -
+
- {/* Custom Variants */} -
-
-
-

Custom Variants

-

- Create custom aliases for providers with specific accounts. -

+ + + {/* Quick Actions */} +
+
+ Tools +
+
+ setViewMode('config')} + /> + setViewMode('logs')} + /> +
+
+ + + {/* Footer Stats */} +
+
+ + {providers.length} provider{providers.length !== 1 ? 's' : ''} + + + + {providers.filter((p) => p.authenticated).length} connected +
+
- {isLoading ? ( -
Loading variants...
+ {/* Right Panel */} +
+ {viewMode === 'config' ? ( +
+ +
+ ) : viewMode === 'logs' ? ( +
+ +
+ ) : selectedStatus ? ( + + setAddAccountProvider({ + provider: selectedStatus.provider, + displayName: selectedStatus.displayName, + }) + } + /> ) : ( - + setWizardOpen(true)} /> )}
+ {/* Dialogs */} setWizardOpen(false)} />