mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 16:19:12 +00:00
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:
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user