Skip to content

Commit f8648be

Browse files
committed
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
1 parent 819a201 commit f8648be

25 files changed

+2353
-187
lines changed

src/cliproxy/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,8 @@ export {
103103
} from './auth-handler';
104104

105105
// Stats fetcher
106-
export type { ClipproxyStats } from './stats-fetcher';
107-
export { fetchClipproxyStats, isClipproxyRunning } from './stats-fetcher';
106+
export type { CliproxyStats } from './stats-fetcher';
107+
export { fetchCliproxyStats, isCliproxyRunning } from './stats-fetcher';
108108

109109
// OpenAI compatibility layer
110110
export type { OpenAICompatProvider, OpenAICompatModel } from './openai-compat-manager';

src/cliproxy/stats-fetcher.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import { CCS_INTERNAL_API_KEY, CLIPROXY_DEFAULT_PORT } from './config-generator';
99

1010
/** Usage statistics from CLIProxyAPI */
11-
export interface ClipproxyStats {
11+
export interface CliproxyStats {
1212
/** Total number of requests processed */
1313
totalRequests: number;
1414
/** Token counts */
@@ -59,9 +59,9 @@ interface UsageApiResponse {
5959
* @param port CLIProxyAPI port (default: 8317)
6060
* @returns Stats object or null if unavailable
6161
*/
62-
export async function fetchClipproxyStats(
62+
export async function fetchCliproxyStats(
6363
port: number = CLIPROXY_DEFAULT_PORT
64-
): Promise<ClipproxyStats | null> {
64+
): Promise<CliproxyStats | null> {
6565
try {
6666
const controller = new AbortController();
6767
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout
@@ -123,7 +123,7 @@ export async function fetchClipproxyStats(
123123
* @param port CLIProxyAPI port (default: 8317)
124124
* @returns true if proxy is running
125125
*/
126-
export async function isClipproxyRunning(port: number = CLIPROXY_DEFAULT_PORT): Promise<boolean> {
126+
export async function isCliproxyRunning(port: number = CLIPROXY_DEFAULT_PORT): Promise<boolean> {
127127
try {
128128
const controller = new AbortController();
129129
const timeoutId = setTimeout(() => controller.abort(), 1000); // 1s timeout

src/web-server/routes.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { Config, Settings } from '../types/config';
1212
import { expandPath } from '../utils/helpers';
1313
import { runHealthChecks, fixHealthIssue } from './health-service';
1414
import { getAllAuthStatus, getOAuthConfig, initializeAccounts } from '../cliproxy/auth-handler';
15-
import { fetchClipproxyStats, isClipproxyRunning } from '../cliproxy/stats-fetcher';
15+
import { fetchCliproxyStats, isCliproxyRunning } from '../cliproxy/stats-fetcher';
1616
import {
1717
listOpenAICompatProviders,
1818
getOpenAICompatProvider,
@@ -1036,12 +1036,12 @@ apiRoutes.get('/files', (_req: Request, res: Response): void => {
10361036

10371037
/**
10381038
* GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics
1039-
* Returns: ClipproxyStats or error if proxy not running
1039+
* Returns: CliproxyStats or error if proxy not running
10401040
*/
10411041
apiRoutes.get('/cliproxy/stats', async (_req: Request, res: Response): Promise<void> => {
10421042
try {
10431043
// Check if proxy is running first
1044-
const running = await isClipproxyRunning();
1044+
const running = await isCliproxyRunning();
10451045
if (!running) {
10461046
res.status(503).json({
10471047
error: 'CLIProxyAPI not running',
@@ -1051,7 +1051,7 @@ apiRoutes.get('/cliproxy/stats', async (_req: Request, res: Response): Promise<v
10511051
}
10521052

10531053
// Fetch stats from management API
1054-
const stats = await fetchClipproxyStats();
1054+
const stats = await fetchCliproxyStats();
10551055
if (!stats) {
10561056
res.status(503).json({
10571057
error: 'Stats unavailable',
@@ -1072,7 +1072,7 @@ apiRoutes.get('/cliproxy/stats', async (_req: Request, res: Response): Promise<v
10721072
*/
10731073
apiRoutes.get('/cliproxy/status', async (_req: Request, res: Response): Promise<void> => {
10741074
try {
1075-
const running = await isClipproxyRunning();
1075+
const running = await isCliproxyRunning();
10761076
res.json({ running });
10771077
} catch (error) {
10781078
res.status(500).json({ error: (error as Error).message });

ui/bun.lock

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,14 @@
2929
"react-day-picker": "^9.12.0",
3030
"react-dom": "^19.2.0",
3131
"react-hook-form": "^7.68.0",
32+
"react-resizable-panels": "^3.0.6",
3233
"react-router-dom": "^7.10.1",
3334
"react-simple-code-editor": "^0.14.1",
35+
"react-virtuoso": "^4.17.0",
3436
"recharts": "^2.12.0",
3537
"sonner": "^2.0.7",
3638
"tailwind-merge": "^3.4.0",
39+
"yaml": "^2.8.2",
3740
"zod": "^4.1.13",
3841
},
3942
"devDependencies": {
@@ -708,6 +711,8 @@
708711

709712
"react-remove-scroll-bar": ["[email protected]", "", { "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=="],
710713

714+
"react-resizable-panels": ["[email protected]", "", { "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=="],
715+
711716
"react-router": ["[email protected]", "", { "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=="],
712717

713718
"react-router-dom": ["[email protected]", "", { "dependencies": { "react-router": "7.10.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw=="],
@@ -720,6 +725,8 @@
720725

721726
"react-transition-group": ["[email protected]", "", { "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=="],
722727

728+
"react-virtuoso": ["[email protected]", "", { "peerDependencies": { "react": ">=16 || >=17 || >= 18 || >= 19", "react-dom": ">=16 || >=17 || >= 18 || >=19" } }, "sha512-od3pi2v13v31uzn5zPXC2u3ouISFCVhjFVFch2VvS2Cx7pWA2F1aJa3XhNTN2F07M3lhfnMnsmGeH+7wZICr7w=="],
729+
723730
"readdirp": ["[email protected]", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
724731

725732
"recharts": ["[email protected]", "", { "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 @@
788795

789796
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
790797

798+
"yaml": ["[email protected]", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
799+
791800
"yocto-queue": ["[email protected]", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
792801

793802
"zod": ["[email protected]", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],

ui/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,14 @@
4040
"react-day-picker": "^9.12.0",
4141
"react-dom": "^19.2.0",
4242
"react-hook-form": "^7.68.0",
43+
"react-resizable-panels": "^3.0.6",
4344
"react-router-dom": "^7.10.1",
4445
"react-simple-code-editor": "^0.14.1",
46+
"react-virtuoso": "^4.17.0",
4547
"recharts": "^2.12.0",
4648
"sonner": "^2.0.7",
4749
"tailwind-merge": "^3.4.0",
50+
"yaml": "^2.8.2",
4851
"zod": "^4.1.13"
4952
},
5053
"devDependencies": {

ui/src/components/analytics/cliproxy-stats-card.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@ import { Badge } from '@/components/ui/badge';
1414
import { ScrollArea } from '@/components/ui/scroll-area';
1515
import { Server, Zap, Cpu, Coins } from 'lucide-react';
1616
import { cn } from '@/lib/utils';
17-
import { useClipproxyStats, useClipproxyStatus } from '@/hooks/use-cliproxy-stats';
17+
import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
1818

19-
interface ClipproxyStatsCardProps {
19+
interface CliproxyStatsCardProps {
2020
className?: string;
2121
}
2222

23-
export function ClipproxyStatsCard({ className }: ClipproxyStatsCardProps) {
24-
const { data: status, isLoading: statusLoading } = useClipproxyStatus();
25-
const { data: stats, isLoading: statsLoading, error } = useClipproxyStats(status?.running);
23+
export function CliproxyStatsCard({ className }: CliproxyStatsCardProps) {
24+
const { data: status, isLoading: statusLoading } = useCliproxyStatus();
25+
const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running);
2626

2727
const isLoading = statusLoading || (status?.running && statsLoading);
2828

ui/src/components/cliproxy-stats-overview.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,15 @@ import {
2525
TrendingUp,
2626
} from 'lucide-react';
2727
import { cn } from '@/lib/utils';
28-
import { useClipproxyStats, useClipproxyStatus } from '@/hooks/use-cliproxy-stats';
28+
import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
2929

30-
interface ClipproxyStatsOverviewProps {
30+
interface CliproxyStatsOverviewProps {
3131
className?: string;
3232
}
3333

34-
export function ClipproxyStatsOverview({ className }: ClipproxyStatsOverviewProps) {
35-
const { data: status, isLoading: statusLoading } = useClipproxyStatus();
36-
const { data: stats, isLoading: statsLoading, error } = useClipproxyStats(status?.running);
34+
export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps) {
35+
const { data: status, isLoading: statusLoading } = useCliproxyStatus();
36+
const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running);
3737

3838
const isLoading = statusLoading || (status?.running && statsLoading);
3939

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* CLIProxy Header Component
3+
* Fixed header with OAuth login buttons, status indicator, and refresh
4+
*/
5+
6+
import { useState, useEffect } from 'react';
7+
import { Button } from '@/components/ui/button';
8+
import { Badge } from '@/components/ui/badge';
9+
import { RefreshCw, Loader2 } from 'lucide-react';
10+
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
11+
import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow';
12+
import { cn } from '@/lib/utils';
13+
14+
interface LoginButtonProps {
15+
provider: string;
16+
displayName: string;
17+
isAuthenticated: boolean;
18+
accountCount: number;
19+
isAuthenticating: boolean;
20+
onLogin: () => void;
21+
}
22+
23+
function LoginButton({
24+
displayName,
25+
isAuthenticated,
26+
accountCount,
27+
isAuthenticating,
28+
onLogin,
29+
}: LoginButtonProps) {
30+
if (isAuthenticating) {
31+
return (
32+
<Button variant="outline" size="sm" disabled className="gap-2">
33+
<Loader2 className="w-3 h-3 animate-spin" />
34+
{displayName}
35+
</Button>
36+
);
37+
}
38+
39+
if (isAuthenticated) {
40+
return (
41+
<Button
42+
variant="outline"
43+
size="sm"
44+
className="gap-2 border-green-500/30 text-green-600 dark:text-green-400"
45+
>
46+
<span className="w-2 h-2 rounded-full bg-green-500" />
47+
{displayName}
48+
{accountCount > 1 && (
49+
<Badge variant="secondary" className="ml-1 h-5 px-1.5 text-[10px]">
50+
{accountCount}
51+
</Badge>
52+
)}
53+
</Button>
54+
);
55+
}
56+
57+
return (
58+
<Button variant="default" size="sm" className="gap-2" onClick={onLogin}>
59+
+ {displayName}
60+
</Button>
61+
);
62+
}
63+
64+
// Helper to format relative time
65+
function formatRelativeTime(date: Date): string {
66+
const diff = Date.now() - date.getTime();
67+
const minutes = Math.floor(diff / 60000);
68+
if (minutes < 1) return 'just now';
69+
if (minutes === 1) return '1m ago';
70+
return `${minutes}m ago`;
71+
}
72+
73+
// Hook for relative time display that updates periodically
74+
function useRelativeTime(date: Date | undefined): string | null {
75+
const [text, setText] = useState<string | null>(() => (date ? formatRelativeTime(date) : null));
76+
77+
useEffect(() => {
78+
if (!date) return;
79+
80+
// Update every 30 seconds via interval only
81+
const interval = setInterval(() => {
82+
setText(formatRelativeTime(date));
83+
}, 30000);
84+
85+
return () => clearInterval(interval);
86+
}, [date]);
87+
88+
// Compute current value on each render if date changes
89+
// This is the pure computation part
90+
const currentText = date ? formatRelativeTime(date) : null;
91+
92+
// Return the more recent of computed or state-based value
93+
// State value will be updated by interval
94+
return date ? currentText : text;
95+
}
96+
97+
interface CliproxyHeaderProps {
98+
onRefresh: () => void;
99+
isRefreshing: boolean;
100+
lastUpdated?: Date;
101+
isRunning?: boolean;
102+
}
103+
104+
export function CliproxyHeader({
105+
onRefresh,
106+
isRefreshing,
107+
lastUpdated,
108+
isRunning = true,
109+
}: CliproxyHeaderProps) {
110+
const { data: authData } = useCliproxyAuth();
111+
const { provider: authProvider, isAuthenticating, startAuth } = useCliproxyAuthFlow();
112+
const lastUpdatedText = useRelativeTime(lastUpdated);
113+
114+
const providers = [
115+
{ id: 'claude', displayName: 'Claude' },
116+
{ id: 'gemini', displayName: 'Gemini' },
117+
{ id: 'codex', displayName: 'Codex' },
118+
{ id: 'agy', displayName: 'Agy' },
119+
];
120+
121+
const getProviderStatus = (providerId: string) => {
122+
const status = authData?.authStatus.find((s) => s.provider === providerId);
123+
return {
124+
isAuthenticated: status?.authenticated ?? false,
125+
accountCount: status?.accounts?.length ?? 0,
126+
};
127+
};
128+
129+
return (
130+
<div className="flex flex-col gap-4 pb-4 border-b">
131+
{/* Top row: Title and Login Buttons */}
132+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
133+
<div>
134+
<h1 className="text-2xl font-bold tracking-tight">CLIProxy</h1>
135+
<p className="text-sm text-muted-foreground mt-1">
136+
Manage OAuth providers and configuration
137+
</p>
138+
</div>
139+
140+
{/* Login Buttons - Wrap on mobile */}
141+
<div className="flex flex-wrap items-center gap-2">
142+
{providers.map((p) => {
143+
const status = getProviderStatus(p.id);
144+
return (
145+
<LoginButton
146+
key={p.id}
147+
provider={p.id}
148+
displayName={p.displayName}
149+
isAuthenticated={status.isAuthenticated}
150+
accountCount={status.accountCount}
151+
isAuthenticating={authProvider === p.id && isAuthenticating}
152+
onLogin={() => startAuth(p.id)}
153+
/>
154+
);
155+
})}
156+
</div>
157+
</div>
158+
159+
{/* Bottom row: Status and Refresh */}
160+
<div className="flex items-center gap-3">
161+
<Badge variant={isRunning ? 'default' : 'secondary'} className="gap-1.5">
162+
<span
163+
className={cn(
164+
'w-2 h-2 rounded-full',
165+
isRunning ? 'bg-green-500 animate-pulse' : 'bg-muted-foreground'
166+
)}
167+
/>
168+
{isRunning ? 'Running' : 'Offline'}
169+
</Badge>
170+
171+
{lastUpdatedText && (
172+
<span className="text-xs text-muted-foreground">{lastUpdatedText}</span>
173+
)}
174+
175+
<Button
176+
variant="ghost"
177+
size="icon"
178+
className="h-8 w-8"
179+
onClick={onRefresh}
180+
disabled={isRefreshing}
181+
>
182+
<RefreshCw className={cn('w-4 h-4', isRefreshing && 'animate-spin')} />
183+
</Button>
184+
</div>
185+
</div>
186+
);
187+
}

0 commit comments

Comments
 (0)