feat(ui): redesign cliproxy page with master-detail layout

- Replace tab-based layout with sidebar + detail panel pattern
- Add provider navigation in left sidebar with quick actions
- Implement provider detail panel with model preferences and accounts
- Add Config Editor and Logs quick access in sidebar
- Fix naming consistency: Clipproxy → Cliproxy (single p) across 8 files
- Fix TypeScript errors for models and stats data shapes
This commit is contained in:
kaitranntt
2025-12-11 15:44:51 -05:00
parent 819a201391
commit f8648be6d9
25 changed files with 2364 additions and 198 deletions
+2 -2
View File
@@ -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';
+4 -4
View File
@@ -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<ClipproxyStats | null> {
): Promise<CliproxyStats | null> {
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<boolean> {
export async function isCliproxyRunning(port: number = CLIPROXY_DEFAULT_PORT): Promise<boolean> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1000); // 1s timeout
+5 -5
View File
@@ -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<void> => {
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<v
}
// Fetch stats from management API
const stats = await fetchClipproxyStats();
const stats = await fetchCliproxyStats();
if (!stats) {
res.status(503).json({
error: 'Stats unavailable',
@@ -1072,7 +1072,7 @@ apiRoutes.get('/cliproxy/stats', async (_req: Request, res: Response): Promise<v
*/
apiRoutes.get('/cliproxy/status', async (_req: Request, res: Response): Promise<void> => {
try {
const running = await isClipproxyRunning();
const running = await isCliproxyRunning();
res.json({ running });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
+9
View File
@@ -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=="],
+3
View File
@@ -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": {
@@ -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);
@@ -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);
@@ -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 (
<Button variant="outline" size="sm" disabled className="gap-2">
<Loader2 className="w-3 h-3 animate-spin" />
{displayName}
</Button>
);
}
if (isAuthenticated) {
return (
<Button
variant="outline"
size="sm"
className="gap-2 border-green-500/30 text-green-600 dark:text-green-400"
>
<span className="w-2 h-2 rounded-full bg-green-500" />
{displayName}
{accountCount > 1 && (
<Badge variant="secondary" className="ml-1 h-5 px-1.5 text-[10px]">
{accountCount}
</Badge>
)}
</Button>
);
}
return (
<Button variant="default" size="sm" className="gap-2" onClick={onLogin}>
+ {displayName}
</Button>
);
}
// 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<string | null>(() => (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 (
<div className="flex flex-col gap-4 pb-4 border-b">
{/* Top row: Title and Login Buttons */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">CLIProxy</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage OAuth providers and configuration
</p>
</div>
{/* Login Buttons - Wrap on mobile */}
<div className="flex flex-wrap items-center gap-2">
{providers.map((p) => {
const status = getProviderStatus(p.id);
return (
<LoginButton
key={p.id}
provider={p.id}
displayName={p.displayName}
isAuthenticated={status.isAuthenticated}
accountCount={status.accountCount}
isAuthenticating={authProvider === p.id && isAuthenticating}
onLogin={() => startAuth(p.id)}
/>
);
})}
</div>
</div>
{/* Bottom row: Status and Refresh */}
<div className="flex items-center gap-3">
<Badge variant={isRunning ? 'default' : 'secondary'} className="gap-1.5">
<span
className={cn(
'w-2 h-2 rounded-full',
isRunning ? 'bg-green-500 animate-pulse' : 'bg-muted-foreground'
)}
/>
{isRunning ? 'Running' : 'Offline'}
</Badge>
{lastUpdatedText && (
<span className="text-xs text-muted-foreground">{lastUpdatedText}</span>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onRefresh}
disabled={isRefreshing}
>
<RefreshCw className={cn('w-4 h-4', isRefreshing && 'animate-spin')} />
</Button>
</div>
</div>
);
}
@@ -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 (
<Tabs
value={activeTab}
onValueChange={(v) => onTabChange(v as CliproxyTabValue)}
className="w-full"
>
<TabsList className="grid w-full grid-cols-3 max-w-md">
{TAB_CONFIG.map(({ value, label, icon: Icon }) => (
<TabsTrigger key={value} value={value} className="gap-2">
<Icon className="w-4 h-4" />
<span className="hidden sm:inline">{label}</span>
</TabsTrigger>
))}
</TabsList>
<TabsContent value="overview" className="mt-6">
{children.overview}
</TabsContent>
<TabsContent value="config" className="mt-6">
{children.config}
</TabsContent>
<TabsContent value="logs" className="mt-6">
{children.logs}
</TabsContent>
</Tabs>
);
}
@@ -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<string>('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<string | null>(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 (
<div className="flex items-center justify-center h-[500px] border rounded-lg">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="border rounded-lg overflow-hidden">
{/* Toolbar */}
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{selectedFile}</span>
{isDirty && isEditingConfig && (
<span className="text-xs text-amber-500 bg-amber-500/10 px-1.5 py-0.5 rounded">
Modified
</span>
)}
</div>
{isEditingConfig && (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowDiff(true)}
disabled={!isDirty}
className="h-7 gap-1.5"
>
<GitCompare className="w-3.5 h-3.5" />
Diff
</Button>
<Button
variant="ghost"
size="sm"
onClick={resetContent}
disabled={!isDirty}
className="h-7 gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
</Button>
<Button
variant="default"
size="sm"
onClick={saveContent}
disabled={!isDirty || !validation.valid || isSaving}
className="h-7 gap-1.5"
>
{isSaving ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Save className="w-3.5 h-3.5" />
)}
Save
</Button>
</div>
)}
</div>
{/* Split Panels */}
<PanelGroup direction="horizontal" className="h-[500px]">
<Panel defaultSize={20} minSize={15} maxSize={30}>
<FileTree files={fileTree} selectedFile={selectedFile} onSelect={handleFileSelect} />
</Panel>
<PanelResizeHandle className="w-1 bg-border hover:bg-accent transition-colors cursor-col-resize" />
<Panel>
<div className="flex flex-col h-full">
<div className="flex-1 overflow-auto">
<YamlEditor
value={currentContent}
onChange={updateContent}
readonly={isReadonly}
errorLine={validation.line}
/>
</div>
<EditorStatusBar validation={validation} isDirty={isDirty && isEditingConfig} />
</div>
</Panel>
</PanelGroup>
{/* Diff Dialog */}
<DiffDialog
open={showDiff}
onClose={() => setShowDiff(false)}
original={originalContent}
modified={content}
onConfirmSave={() => {
saveContent();
setShowDiff(false);
}}
isSaving={isSaving}
/>
</div>
);
}
@@ -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(
<div key={i} className={`flex text-xs font-mono ${isChanged ? 'bg-amber-500/10' : ''}`}>
<div className="w-8 text-right pr-2 text-muted-foreground/50 border-r">{i + 1}</div>
<div className="flex-1 grid grid-cols-2">
<div className={`px-2 ${isChanged ? 'bg-red-500/10 line-through' : ''}`}>
{origLine}
</div>
<div className={`px-2 border-l ${isChanged ? 'bg-green-500/10' : ''}`}>{modLine}</div>
</div>
</div>
);
}
return rows;
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Review Changes</DialogTitle>
</DialogHeader>
<div className="border rounded-lg overflow-hidden">
<div className="grid grid-cols-2 text-xs font-medium bg-muted p-2 border-b">
<div className="text-red-600">Original</div>
<div className="text-green-600 border-l pl-2">Modified</div>
</div>
<ScrollArea className="h-[400px]">
<div className="divide-y">{renderDiff()}</div>
</ScrollArea>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={onConfirmSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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,
})),
},
];
}
@@ -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 <Folder className="w-4 h-4 text-amber-500" />;
}
if (icon === 'key') {
return <Key className="w-4 h-4 text-green-500" />;
}
return <FileText className="w-4 h-4 text-blue-500" />;
}
function TreeNode({
node,
depth,
selectedFile,
onSelect,
expandedFolders,
onToggleFolder,
}: {
node: FileNode;
depth: number;
selectedFile: string | null;
onSelect: (path: string) => void;
expandedFolders: Set<string>;
onToggleFolder: (path: string) => void;
}) {
const isExpanded = expandedFolders.has(node.path);
const isSelected = selectedFile === node.path;
return (
<div>
<button
className={cn(
'flex items-center gap-1.5 w-full px-2 py-1 text-sm text-left rounded-md transition-colors',
'hover:bg-muted/50',
isSelected && 'bg-accent/10 border-l-2 border-accent'
)}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={() => {
if (node.type === 'folder') {
onToggleFolder(node.path);
} else {
onSelect(node.path);
}
}}
>
{node.type === 'folder' &&
(isExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
))}
<FileIcon type={node.type} icon={node.icon} />
<span className="truncate flex-1">{node.name}</span>
{node.modified && <span className="text-amber-500 ml-1">*</span>}
</button>
{node.type === 'folder' && isExpanded && node.children && (
<div className="animate-in slide-in-from-top-1 duration-150">
{node.children.map((child) => (
<TreeNode
key={child.path}
node={child}
depth={depth + 1}
selectedFile={selectedFile}
onSelect={onSelect}
expandedFolders={expandedFolders}
onToggleFolder={onToggleFolder}
/>
))}
</div>
)}
</div>
);
}
export function FileTree({ files, selectedFile, onSelect }: FileTreeProps) {
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(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 (
<ScrollArea className="h-full">
<div className="p-2">
{files.map((node) => (
<TreeNode
key={node.path}
node={node}
depth={0}
selectedFile={selectedFile}
onSelect={onSelect}
expandedFolders={expandedFolders}
onToggleFolder={toggleFolder}
/>
))}
</div>
</ScrollArea>
);
}
@@ -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) => (
<Highlight theme={isDark ? themes.nightOwl : themes.github} code={code} language="yaml">
{({ tokens, getLineProps, getTokenProps }) => (
<>
{tokens.map((line, i) => (
<div
key={i}
{...getLineProps({ line })}
className={cn(
'px-2',
errorLine === i + 1 && 'bg-destructive/20 border-l-2 border-destructive'
)}
>
<span className="inline-block w-8 text-right mr-4 text-muted-foreground/50 select-none text-xs">
{i + 1}
</span>
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</div>
))}
</>
)}
</Highlight>
),
[isDark, errorLine]
);
return (
<div
className={cn(
'relative rounded-md border overflow-hidden bg-muted/30 h-full',
isFocused && 'ring-2 ring-ring ring-offset-2 ring-offset-background',
readonly && 'opacity-70',
className
)}
>
<Editor
value={value}
onValueChange={readonly ? () => {} : 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%',
}}
/>
</div>
);
}
interface EditorStatusBarProps {
validation: { valid: boolean; error?: string; line?: number };
isDirty: boolean;
cursorLine?: number;
cursorCol?: number;
}
export function EditorStatusBar({
validation,
isDirty,
cursorLine,
cursorCol,
}: EditorStatusBarProps) {
return (
<div className="flex items-center justify-between px-3 py-1.5 border-t bg-muted/30 text-xs">
<div className="flex items-center gap-4">
{validation.valid ? (
<span className="flex items-center gap-1 text-muted-foreground">
<CheckCircle2 className="w-3 h-3 text-green-500" />
Valid YAML
</span>
) : (
<span className="flex items-center gap-1 text-destructive">
<AlertCircle className="w-3 h-3" />
{validation.error}
{validation.line && ` (line ${validation.line})`}
</span>
)}
{isDirty && <span className="text-amber-500">Unsaved changes</span>}
</div>
{cursorLine && cursorCol && (
<span className="text-muted-foreground">
Ln {cursorLine}, Col {cursorCol}
</span>
)}
</div>
);
}
@@ -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 (
<div className="flex items-center justify-between p-3 border-b last:border-0">
<div className="flex items-center gap-3">
<div className={cn('p-2 rounded-full', config.bg)}>
<Icon className={cn('w-4 h-4', config.color)} />
</div>
<div>
<div className="font-medium text-sm">{email ?? name}</div>
<div className="text-xs text-muted-foreground capitalize">{provider}</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<Badge
variant={
status === 'ready' ? 'outline' : status === 'warning' ? 'secondary' : 'destructive'
}
className="text-xs"
>
{statusMessage}
</Badge>
<div className="text-xs text-muted-foreground mt-0.5">
Expires: {formatExpiry(expiresAt)}
</div>
</div>
{status === 'warning' && onRefresh && (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onRefresh}>
<RefreshCw className="w-4 h-4" />
</Button>
)}
</div>
</div>
);
}
function CredentialHealthSkeleton() {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Credential Health</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-14 bg-muted animate-pulse rounded-lg" />
))}
</div>
</CardContent>
</Card>
);
}
export function CredentialHealthList() {
const { data: authData, isLoading } = useCliproxyAuth();
if (isLoading) {
return <CredentialHealthSkeleton />;
}
// 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 (
<Card>
<CardHeader>
<CardTitle className="text-base">Credential Health</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground text-center py-4">
No credentials configured. Use the login buttons above to authenticate.
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Credential Health</CardTitle>
</CardHeader>
<CardContent className="p-0">
{credentials.map((cred, i) => (
<CredentialRow key={`${cred.provider}-${cred.name}-${i}`} {...cred} />
))}
</CardContent>
</Card>
);
}
@@ -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<string, string> = {
claude: 'bg-orange-500',
gemini: 'bg-blue-500',
codex: 'bg-green-500',
agy: 'bg-purple-500',
};
return <div className={`w-3 h-3 rounded-full ${colors[provider] ?? 'bg-gray-500'}`} />;
}
function ProviderModelSelect({
provider,
displayName,
currentModel,
availableModels,
onModelChange,
isUpdating,
}: ProviderModelSelectProps) {
return (
<div className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center gap-2">
<ProviderIcon provider={provider} />
<span className="font-medium">{displayName}</span>
</div>
<div className="flex items-center gap-2">
{isUpdating && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
<Select
value={currentModel}
onValueChange={onModelChange}
disabled={isUpdating || availableModels.length === 0}
>
<SelectTrigger className="w-[200px]">
<SelectValue
placeholder={availableModels.length === 0 ? 'No models available' : 'Select model'}
/>
</SelectTrigger>
<SelectContent>
{availableModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}
function ModelPreferencesSkeleton() {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Model Preferences</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-14 bg-muted animate-pulse rounded-lg" />
))}
</div>
</CardContent>
</Card>
);
}
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 <ModelPreferencesSkeleton />;
}
const getProviderModels = (providerId: string) => {
const providerData = modelsData?.providers?.[providerId];
return {
current: providerData?.currentModel ?? '',
available: providerData?.availableModels ?? [],
};
};
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Model Preferences</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{providers.map((p) => {
const models = getProviderModels(p.id);
return (
<ProviderModelSelect
key={p.id}
provider={p.id}
displayName={p.displayName}
currentModel={models.current}
availableModels={models.available}
onModelChange={(model) => updateModel.mutate({ provider: p.id, model })}
isUpdating={updateModel.isPending && updateModel.variables?.provider === p.id}
/>
);
})}
</div>
</CardContent>
</Card>
);
}
@@ -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 (
<Card className="py-3">
<CardContent className="p-0 px-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-muted text-muted-foreground">{icon}</div>
<div>
<p className="text-2xl font-bold">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</div>
</CardContent>
</Card>
);
}
function StatsSkeleton() {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i} className="py-3">
<CardContent className="p-0 px-4">
<div className="h-12 bg-muted animate-pulse rounded" />
</CardContent>
</Card>
))}
</div>
);
}
export function QuickStatsRow() {
const { data: stats, isLoading } = useCliproxyStats();
if (isLoading) {
return <StatsSkeleton />;
}
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<string, { models?: Record<string, unknown> }>) ?? {};
const modelCount = Object.keys(apis).reduce((count, api) => {
const apiData = apis[api];
return count + Object.keys(apiData?.models ?? {}).length;
}, 0);
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
icon={<Activity className="w-4 h-4" />}
label="Total Requests"
value={totalRequests}
/>
<StatCard
icon={<CheckCircle2 className="w-4 h-4" />}
label="Success Rate"
value={`${successRate}%`}
/>
<StatCard icon={<Coins className="w-4 h-4" />} label="Total Tokens" value={tokenDisplay} />
<StatCard icon={<Cpu className="w-4 h-4" />} label="Active Models" value={modelCount} />
</div>
);
}
+188
View File
@@ -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<string, string> = {
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<AuthFlowState>({
provider: null,
isAuthenticating: false,
error: null,
});
const popupRef = useRef<Window | null>(null);
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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,
};
}
+169
View File
@@ -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),
});
}
+195
View File
@@ -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<LogsState>({
logs: [],
isPaused: false,
isConnected: false,
lastTimestamp: null,
stats: { total: 0, errors: 0, warnings: 0 },
});
const bufferRef = useRef<LogEntry[]>([]);
const pollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const flushIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isPausedRef = useRef(false);
const lastTimestampRef = useRef<string | null>(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<string>('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,
};
}
+8 -8
View File
@@ -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<ClipproxyStats> {
async function fetchCliproxyStats(): Promise<CliproxyStats> {
const response = await fetch('/api/cliproxy/stats');
if (!response.ok) {
const error = await response.json();
@@ -39,7 +39,7 @@ async function fetchClipproxyStats(): Promise<ClipproxyStats> {
/**
* Fetch CLIProxy running status
*/
async function fetchClipproxyStatus(): Promise<ClipproxyStatus> {
async function fetchCliproxyStatus(): Promise<CliproxyStatus> {
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<ClipproxyStatus> {
/**
* 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,
+32
View File
@@ -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);
},
});
}
+47
View File
@@ -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<string, OAuthAccount[]>;
@@ -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<string, unknown> }>('/cliproxy/usage'),
models: () =>
request<{
providers: Record<string, { currentModel: string; availableModels: string[] }>;
}>('/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<string> => {
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<void> => {
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<string> => {
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'),
+2 -2
View File
@@ -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 */}
<ClipproxyStatsCard className="lg:col-span-2" />
<CliproxyStatsCard className="lg:col-span-2" />
</div>
{/* Model Details Popover - positioned at cursor */}
+493 -167
View File
@@ -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<string, { bg: string; text: string; letter: string }> = {
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 (
<div
className={cn(
'flex items-center justify-center w-8 h-8 rounded-lg font-semibold text-sm',
config.bg,
config.text,
className
)}
>
{config.letter}
</div>
);
}
// Sidebar provider item
function ProviderSidebarItem({
status,
isSelected,
onSelect,
}: {
status: AuthStatus;
isSelected: boolean;
onSelect: () => void;
}) {
const accountCount = status.accounts?.length || 0;
return (
<button
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors cursor-pointer text-left',
isSelected
? 'bg-primary/10 border border-primary/20'
: 'hover:bg-muted border border-transparent'
)}
onClick={onSelect}
>
<ProviderIcon provider={status.provider} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{status.displayName}</span>
{accountCount > 0 && (
<Badge variant="secondary" className="text-[10px] h-4 px-1">
{accountCount}
</Badge>
)}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{status.authenticated ? (
<>
<Check className="w-3 h-3 text-green-600" />
<span className="text-xs text-green-600">Connected</span>
</>
) : (
<>
<X className="w-3 h-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Not connected</span>
</>
)}
</div>
</div>
</button>
);
}
// Quick action item in sidebar
function QuickActionItem({
icon: Icon,
label,
isActive,
onClick,
}: {
icon: React.ElementType;
label: string;
isActive: boolean;
onClick: () => void;
}) {
return (
<button
className={cn(
'w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors cursor-pointer text-left text-sm',
isActive ? 'bg-muted font-medium' : 'hover:bg-muted/50 text-muted-foreground'
)}
onClick={onClick}
>
<Icon className="w-4 h-4" />
<span>{label}</span>
</button>
);
}
// Account badge with actions
function AccountBadge({
account,
onSetDefault,
@@ -39,122 +177,239 @@ function AccountBadge({
isRemoving: boolean;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-full border transition-colors hover:bg-muted/80 ${
account.isDefault
? 'border-primary/30 bg-primary/10 text-primary font-medium'
: 'border-muted bg-muted/40'
}`}
>
<User className="w-3 h-3" />
<span className="max-w-[200px] truncate">{account.email || account.id}</span>
{account.isDefault && <Star className="w-3 h-3 fill-current" />}
<ChevronDown className="w-3 h-3 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<div className="px-2 py-1.5 text-xs text-muted-foreground bg-muted/30 -mx-1 -mt-1 mb-1 border-b">
<div className="font-medium text-foreground mb-0.5 truncate">
{account.email || account.id}
</div>
{account.lastUsedAt && (
<div>Last used: {new Date(account.lastUsedAt).toLocaleDateString()}</div>
)}
</div>
{!account.isDefault && (
<DropdownMenuItem onClick={onSetDefault}>
<Star className="w-4 h-4 mr-2" />
Set as default
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-red-600 focus:text-red-600 focus:bg-red-50 dark:focus:bg-red-950/30"
onClick={onRemove}
disabled={isRemoving}
>
<Trash2 className="w-4 h-4 mr-2" />
{isRemoving ? 'Removing...' : 'Remove account'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function ProviderRow({
status,
setDefaultMutation,
removeMutation,
onAddAccount,
}: {
status: AuthStatus;
setDefaultMutation: ReturnType<typeof useSetDefaultAccount>;
removeMutation: ReturnType<typeof useRemoveAccount>;
onAddAccount: () => void;
}) {
const accounts = status.accounts || [];
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between p-4 border-b last:border-0 hover:bg-muted/5 transition-colors gap-4">
<div className="flex items-center gap-4 min-w-[180px]">
<div
className={cn(
'flex items-center justify-between p-3 rounded-lg border transition-colors',
account.isDefault ? 'border-primary/30 bg-primary/5' : 'border-border hover:bg-muted/30'
)}
>
<div className="flex items-center gap-3">
<div
className={`flex items-center justify-center w-8 h-8 rounded-full ${
status.authenticated
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-muted text-muted-foreground'
}`}
className={cn(
'flex items-center justify-center w-8 h-8 rounded-full',
account.isDefault ? 'bg-primary/10' : 'bg-muted'
)}
>
{status.authenticated ? <Check className="w-5 h-5" /> : <X className="w-5 h-5" />}
<User className="w-4 h-4" />
</div>
<div>
<div className="font-medium flex items-center gap-2">
{status.displayName}
{accounts.length > 0 && (
<Badge variant="secondary" className="text-[10px] h-5 px-1.5 font-normal">
{accounts.length}
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{account.email || account.id}</span>
{account.isDefault && (
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
<Star className="w-2.5 h-2.5 fill-current" />
Default
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">{status.provider}</div>
{account.lastUsedAt && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<Clock className="w-3 h-3" />
Last used: {new Date(account.lastUsedAt).toLocaleDateString()}
</div>
)}
</div>
</div>
<div className="flex-1 flex items-center gap-2 flex-wrap">
{status.authenticated && accounts.length > 0 ? (
accounts.map((account) => (
<AccountBadge
key={account.id}
account={account}
onSetDefault={() =>
setDefaultMutation.mutate({
provider: status.provider,
accountId: account.id,
})
}
onRemove={() =>
removeMutation.mutate({
provider: status.provider,
accountId: account.id,
})
}
isRemoving={removeMutation.isPending}
/>
))
) : (
<div className="text-sm text-muted-foreground italic">
{status.authenticated
? 'Authenticated (No specific accounts tracked)'
: 'Not authenticated'}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!account.isDefault && (
<DropdownMenuItem onClick={onSetDefault}>
<Star className="w-4 h-4 mr-2" />
Set as default
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={onRemove}
disabled={isRemoving}
>
<Trash2 className="w-4 h-4 mr-2" />
{isRemoving ? 'Removing...' : 'Remove account'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
// 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 (
<div className="flex-1 flex flex-col min-w-0 p-6 overflow-auto">
{/* Header */}
<div className="flex items-start justify-between mb-6">
<div className="flex items-center gap-4">
<ProviderIcon provider={status.provider} className="w-12 h-12 text-lg" />
<div>
<h2 className="text-xl font-semibold">{status.displayName}</h2>
<div className="flex items-center gap-2 mt-1">
{status.authenticated ? (
<Badge variant="outline" className="text-green-600 border-green-200 bg-green-50">
<Shield className="w-3 h-3 mr-1" />
Authenticated
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
<X className="w-3 h-3 mr-1" />
Not connected
</Badge>
)}
{providerRequestCount !== null && providerRequestCount > 0 && (
<Badge variant="secondary" className="text-xs">
<Zap className="w-3 h-3 mr-1" />
{providerRequestCount.toLocaleString()} requests
</Badge>
)}
</div>
</div>
)}
</div>
<Button onClick={onAddAccount} className="gap-2">
<Plus className="w-4 h-4" />
Add Account
</Button>
</div>
<div className="flex items-center gap-2">
{/* Show Add Account button for all - opens dialog with instructions */}
<Button variant="outline" size="sm" className="h-7 text-xs gap-1" onClick={onAddAccount}>
<Plus className="w-3 h-3" />
Add Account
{/* Model Preference */}
<Card className="mb-4">
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Settings className="w-4 h-4" />
Model Preference
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-0">
<div className="flex items-center gap-4">
<Select
value={currentModel || ''}
onValueChange={(value) => {
updateModelMutation.mutate({ provider: status.provider, model: value });
}}
disabled={updateModelMutation.isPending}
>
<SelectTrigger className="w-[280px]">
<SelectValue placeholder="Select preferred model" />
</SelectTrigger>
<SelectContent>
{providerModels.map((model: string) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
{updateModelMutation.isPending && (
<span className="text-xs text-muted-foreground">Updating...</span>
)}
</div>
</CardContent>
</Card>
{/* Accounts */}
<Card className="flex-1">
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm font-medium flex items-center justify-between">
<div className="flex items-center gap-2">
<User className="w-4 h-4" />
Accounts
{accounts.length > 0 && (
<Badge variant="secondary" className="text-xs">
{accounts.length}
</Badge>
)}
</div>
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-0">
{accounts.length > 0 ? (
<div className="space-y-2">
{accounts.map((account) => (
<AccountBadge
key={account.id}
account={account}
onSetDefault={() =>
setDefaultMutation.mutate({
provider: status.provider,
accountId: account.id,
})
}
onRemove={() =>
removeMutation.mutate({
provider: status.provider,
accountId: account.id,
})
}
isRemoving={removeMutation.isPending}
/>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-3">
<User className="w-6 h-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground mb-3">
{status.authenticated
? 'No specific accounts tracked'
: 'Connect an account to get started'}
</p>
<Button variant="outline" size="sm" onClick={onAddAccount} className="gap-2">
<Plus className="w-4 h-4" />
Add Account
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}
// Empty state for right panel
function EmptyProviderState({ onSetup }: { onSetup: () => void }) {
return (
<div className="flex-1 flex items-center justify-center bg-muted/20">
<div className="text-center max-w-md px-8">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto mb-6">
<Zap className="w-8 h-8 text-muted-foreground" />
</div>
<h2 className="text-xl font-semibold mb-2">CLIProxy Manager</h2>
<p className="text-muted-foreground mb-6">
Manage OAuth authentication for Claude CLI proxy providers. Select a provider from the
sidebar or run the quick setup wizard.
</p>
<Button onClick={onSetup} className="gap-2">
<Sparkles className="w-4 h-4" />
Quick Setup
</Button>
</div>
</div>
@@ -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<string | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>('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 (
<div className="p-6 max-w-6xl mx-auto space-y-8">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">CLIProxy</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage OAuth-based provider variants and multi-account configurations
</p>
</div>
<Button onClick={() => setWizardOpen(true)}>
<Sparkles className="w-4 h-4 mr-2" />
Quick Setup
</Button>
</div>
<div className="h-[calc(100vh-100px)] flex">
{/* Left Sidebar */}
<div className="w-64 border-r flex flex-col bg-muted/30">
{/* Header */}
<div className="p-4 border-b bg-background">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Zap className="w-5 h-5 text-primary" />
<h1 className="font-semibold">CLIProxy</h1>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleRefresh}
disabled={isFetching}
>
<RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
</Button>
</div>
{/* Session Statistics */}
<ClipproxyStatsOverview />
{/* Built-in Profiles with Account Management */}
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold tracking-tight">Built-in Providers</h2>
<p className="text-sm text-muted-foreground">
Manage authentication status and accounts for built-in providers.
</p>
<Button
variant="outline"
size="sm"
className="w-full gap-2"
onClick={() => setWizardOpen(true)}
>
<Sparkles className="w-4 h-4" />
Quick Setup
</Button>
</div>
<Card className="py-0 gap-0">
<CardContent className="p-0">
{/* Providers List */}
<ScrollArea className="flex-1">
<div className="p-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2">
Providers
</div>
{authLoading ? (
<div className="p-8 text-center text-muted-foreground">
Loading authentication status...
<div className="space-y-2 px-2">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-14 w-full rounded-lg" />
))}
</div>
) : (
<div className="flex flex-col">
{authData?.authStatus.map((status) => (
<ProviderRow
<div className="space-y-1">
{providers.map((status) => (
<ProviderSidebarItem
key={status.provider}
status={status}
setDefaultMutation={setDefaultMutation}
removeMutation={removeMutation}
onAddAccount={() =>
setAddAccountProvider({
provider: status.provider,
displayName: status.displayName,
})
}
isSelected={effectiveProvider === status.provider && viewMode === 'overview'}
onSelect={() => {
setSelectedProvider(status.provider);
setViewMode('overview');
}}
/>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Custom Variants */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">Custom Variants</h2>
<p className="text-sm text-muted-foreground">
Create custom aliases for providers with specific accounts.
</p>
<Separator className="my-2" />
{/* Quick Actions */}
<div className="p-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2">
Tools
</div>
<div className="space-y-1">
<QuickActionItem
icon={FileText}
label="Config Editor"
isActive={viewMode === 'config'}
onClick={() => setViewMode('config')}
/>
<QuickActionItem
icon={Terminal}
label="Logs"
isActive={viewMode === 'logs'}
onClick={() => setViewMode('logs')}
/>
</div>
</div>
</ScrollArea>
{/* Footer Stats */}
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>
{providers.length} provider{providers.length !== 1 ? 's' : ''}
</span>
<span className="flex items-center gap-1">
<Check className="w-3 h-3 text-green-600" />
{providers.filter((p) => p.authenticated).length} connected
</span>
</div>
</div>
</div>
{isLoading ? (
<div className="text-muted-foreground">Loading variants...</div>
{/* Right Panel */}
<div className="flex-1 flex flex-col min-w-0 bg-background">
{viewMode === 'config' ? (
<div className="flex-1 p-4">
<ConfigSplitView />
</div>
) : viewMode === 'logs' ? (
<div className="flex-1 p-4">
<LogViewer />
</div>
) : selectedStatus ? (
<ProviderDetailPanel
status={selectedStatus}
onAddAccount={() =>
setAddAccountProvider({
provider: selectedStatus.provider,
displayName: selectedStatus.displayName,
})
}
/>
) : (
<CliproxyTable data={data?.variants || []} />
<EmptyProviderState onSetup={() => setWizardOpen(true)} />
)}
</div>
{/* Dialogs */}
<QuickSetupWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
<AddAccountDialog
open={addAccountProvider !== null}