mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 22:14:22 +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:
1 parent
819a201391
commit
f8648be6d9
25 files changed
+2364
-198
No files matched your search
@@ -103,8 +103,8 @@ export {
|
|||||||
} from './auth-handler';
|
} from './auth-handler';
|
||||||
|
|
||||||
// Stats fetcher
|
// Stats fetcher
|
||||||
export type { ClipproxyStats } from './stats-fetcher';
|
export type { CliproxyStats } from './stats-fetcher';
|
||||||
export { fetchClipproxyStats, isClipproxyRunning } from './stats-fetcher';
|
export { fetchCliproxyStats, isCliproxyRunning } from './stats-fetcher';
|
||||||
|
|
||||||
// OpenAI compatibility layer
|
// OpenAI compatibility layer
|
||||||
export type { OpenAICompatProvider, OpenAICompatModel } from './openai-compat-manager';
|
export type { OpenAICompatProvider, OpenAICompatModel } from './openai-compat-manager';
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import { CCS_INTERNAL_API_KEY, CLIPROXY_DEFAULT_PORT } from './config-generator';
|
import { CCS_INTERNAL_API_KEY, CLIPROXY_DEFAULT_PORT } from './config-generator';
|
||||||
|
|
||||||
/** Usage statistics from CLIProxyAPI */
|
/** Usage statistics from CLIProxyAPI */
|
||||||
export interface ClipproxyStats {
|
export interface CliproxyStats {
|
||||||
/** Total number of requests processed */
|
/** Total number of requests processed */
|
||||||
totalRequests: number;
|
totalRequests: number;
|
||||||
/** Token counts */
|
/** Token counts */
|
||||||
@@ -59,9 +59,9 @@ interface UsageApiResponse {
|
|||||||
* @param port CLIProxyAPI port (default: 8317)
|
* @param port CLIProxyAPI port (default: 8317)
|
||||||
* @returns Stats object or null if unavailable
|
* @returns Stats object or null if unavailable
|
||||||
*/
|
*/
|
||||||
export async function fetchClipproxyStats(
|
export async function fetchCliproxyStats(
|
||||||
port: number = CLIPROXY_DEFAULT_PORT
|
port: number = CLIPROXY_DEFAULT_PORT
|
||||||
): Promise<ClipproxyStats | null> {
|
): Promise<CliproxyStats | null> {
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout
|
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout
|
||||||
@@ -123,7 +123,7 @@ export async function fetchClipproxyStats(
|
|||||||
* @param port CLIProxyAPI port (default: 8317)
|
* @param port CLIProxyAPI port (default: 8317)
|
||||||
* @returns true if proxy is running
|
* @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 {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 1000); // 1s timeout
|
const timeoutId = setTimeout(() => controller.abort(), 1000); // 1s timeout
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Config, Settings } from '../types/config';
|
|||||||
import { expandPath } from '../utils/helpers';
|
import { expandPath } from '../utils/helpers';
|
||||||
import { runHealthChecks, fixHealthIssue } from './health-service';
|
import { runHealthChecks, fixHealthIssue } from './health-service';
|
||||||
import { getAllAuthStatus, getOAuthConfig, initializeAccounts } from '../cliproxy/auth-handler';
|
import { getAllAuthStatus, getOAuthConfig, initializeAccounts } from '../cliproxy/auth-handler';
|
||||||
import { fetchClipproxyStats, isClipproxyRunning } from '../cliproxy/stats-fetcher';
|
import { fetchCliproxyStats, isCliproxyRunning } from '../cliproxy/stats-fetcher';
|
||||||
import {
|
import {
|
||||||
listOpenAICompatProviders,
|
listOpenAICompatProviders,
|
||||||
getOpenAICompatProvider,
|
getOpenAICompatProvider,
|
||||||
@@ -1036,12 +1036,12 @@ apiRoutes.get('/files', (_req: Request, res: Response): void => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics
|
* 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> => {
|
apiRoutes.get('/cliproxy/stats', async (_req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
// Check if proxy is running first
|
// Check if proxy is running first
|
||||||
const running = await isClipproxyRunning();
|
const running = await isCliproxyRunning();
|
||||||
if (!running) {
|
if (!running) {
|
||||||
res.status(503).json({
|
res.status(503).json({
|
||||||
error: 'CLIProxyAPI not running',
|
error: 'CLIProxyAPI not running',
|
||||||
@@ -1051,7 +1051,7 @@ apiRoutes.get('/cliproxy/stats', async (_req: Request, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch stats from management API
|
// Fetch stats from management API
|
||||||
const stats = await fetchClipproxyStats();
|
const stats = await fetchCliproxyStats();
|
||||||
if (!stats) {
|
if (!stats) {
|
||||||
res.status(503).json({
|
res.status(503).json({
|
||||||
error: 'Stats unavailable',
|
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> => {
|
apiRoutes.get('/cliproxy/status', async (_req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const running = await isClipproxyRunning();
|
const running = await isCliproxyRunning();
|
||||||
res.json({ running });
|
res.json({ running });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: (error as Error).message });
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
|||||||
@@ -29,11 +29,14 @@
|
|||||||
"react-day-picker": "^9.12.0",
|
"react-day-picker": "^9.12.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.68.0",
|
"react-hook-form": "^7.68.0",
|
||||||
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
|
"react-virtuoso": "^4.17.0",
|
||||||
"recharts": "^2.12.0",
|
"recharts": "^2.12.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"yaml": "^2.8.2",
|
||||||
"zod": "^4.1.13",
|
"zod": "^4.1.13",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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-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": ["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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||||
|
|
||||||
"zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],
|
"zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],
|
||||||
|
|||||||
@@ -40,11 +40,14 @@
|
|||||||
"react-day-picker": "^9.12.0",
|
"react-day-picker": "^9.12.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.68.0",
|
"react-hook-form": "^7.68.0",
|
||||||
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
|
"react-virtuoso": "^4.17.0",
|
||||||
"recharts": "^2.12.0",
|
"recharts": "^2.12.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"yaml": "^2.8.2",
|
||||||
"zod": "^4.1.13"
|
"zod": "^4.1.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -14,15 +14,15 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Server, Zap, Cpu, Coins } from 'lucide-react';
|
import { Server, Zap, Cpu, Coins } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
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;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClipproxyStatsCard({ className }: ClipproxyStatsCardProps) {
|
export function CliproxyStatsCard({ className }: CliproxyStatsCardProps) {
|
||||||
const { data: status, isLoading: statusLoading } = useClipproxyStatus();
|
const { data: status, isLoading: statusLoading } = useCliproxyStatus();
|
||||||
const { data: stats, isLoading: statsLoading, error } = useClipproxyStats(status?.running);
|
const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running);
|
||||||
|
|
||||||
const isLoading = statusLoading || (status?.running && statsLoading);
|
const isLoading = statusLoading || (status?.running && statsLoading);
|
||||||
|
|
||||||
|
|||||||
@@ -25,15 +25,15 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
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;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClipproxyStatsOverview({ className }: ClipproxyStatsOverviewProps) {
|
export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps) {
|
||||||
const { data: status, isLoading: statusLoading } = useClipproxyStatus();
|
const { data: status, isLoading: statusLoading } = useCliproxyStatus();
|
||||||
const { data: stats, isLoading: statsLoading, error } = useClipproxyStats(status?.running);
|
const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running);
|
||||||
|
|
||||||
const isLoading = statusLoading || (status?.running && statsLoading);
|
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';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
/** CLIProxy usage statistics */
|
/** CLIProxy usage statistics */
|
||||||
export interface ClipproxyStats {
|
export interface CliproxyStats {
|
||||||
totalRequests: number;
|
totalRequests: number;
|
||||||
tokens: {
|
tokens: {
|
||||||
input: number;
|
input: number;
|
||||||
@@ -20,14 +20,14 @@ export interface ClipproxyStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** CLIProxy running status */
|
/** CLIProxy running status */
|
||||||
export interface ClipproxyStatus {
|
export interface CliproxyStatus {
|
||||||
running: boolean;
|
running: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch CLIProxy stats from API
|
* Fetch CLIProxy stats from API
|
||||||
*/
|
*/
|
||||||
async function fetchClipproxyStats(): Promise<ClipproxyStats> {
|
async function fetchCliproxyStats(): Promise<CliproxyStats> {
|
||||||
const response = await fetch('/api/cliproxy/stats');
|
const response = await fetch('/api/cliproxy/stats');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
@@ -39,7 +39,7 @@ async function fetchClipproxyStats(): Promise<ClipproxyStats> {
|
|||||||
/**
|
/**
|
||||||
* Fetch CLIProxy running status
|
* Fetch CLIProxy running status
|
||||||
*/
|
*/
|
||||||
async function fetchClipproxyStatus(): Promise<ClipproxyStatus> {
|
async function fetchCliproxyStatus(): Promise<CliproxyStatus> {
|
||||||
const response = await fetch('/api/cliproxy/status');
|
const response = await fetch('/api/cliproxy/status');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch status');
|
throw new Error('Failed to fetch status');
|
||||||
@@ -50,10 +50,10 @@ async function fetchClipproxyStatus(): Promise<ClipproxyStatus> {
|
|||||||
/**
|
/**
|
||||||
* Hook to get CLIProxy running status
|
* Hook to get CLIProxy running status
|
||||||
*/
|
*/
|
||||||
export function useClipproxyStatus() {
|
export function useCliproxyStatus() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['cliproxy-status'],
|
queryKey: ['cliproxy-status'],
|
||||||
queryFn: fetchClipproxyStatus,
|
queryFn: fetchCliproxyStatus,
|
||||||
refetchInterval: 10000, // Check every 10 seconds
|
refetchInterval: 10000, // Check every 10 seconds
|
||||||
retry: 1,
|
retry: 1,
|
||||||
});
|
});
|
||||||
@@ -62,10 +62,10 @@ export function useClipproxyStatus() {
|
|||||||
/**
|
/**
|
||||||
* Hook to get CLIProxy usage stats
|
* Hook to get CLIProxy usage stats
|
||||||
*/
|
*/
|
||||||
export function useClipproxyStats(enabled = true) {
|
export function useCliproxyStats(enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['cliproxy-stats'],
|
queryKey: ['cliproxy-stats'],
|
||||||
queryFn: fetchClipproxyStats,
|
queryFn: fetchCliproxyStats,
|
||||||
enabled,
|
enabled,
|
||||||
refetchInterval: 30000, // Refresh every 30 seconds
|
refetchInterval: 30000, // Refresh every 30 seconds
|
||||||
retry: 1,
|
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;
|
defaultAccount?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Auth file info for Config tab */
|
||||||
|
export interface AuthFile {
|
||||||
|
name: string;
|
||||||
|
provider?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Provider accounts summary */
|
/** Provider accounts summary */
|
||||||
export type ProviderAccountsMap = Record<string, OAuthAccount[]>;
|
export type ProviderAccountsMap = Record<string, OAuthAccount[]>;
|
||||||
|
|
||||||
@@ -146,6 +152,47 @@ export const api = {
|
|||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
delete: (name: string) => request(`/cliproxy/${name}`, { method: 'DELETE' }),
|
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
|
// Multi-account management
|
||||||
accounts: {
|
accounts: {
|
||||||
list: () => request<{ accounts: ProviderAccountsMap }>('/cliproxy/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 { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart';
|
||||||
import { ModelDetailsContent } from '@/components/analytics/model-details-content';
|
import { ModelDetailsContent } from '@/components/analytics/model-details-content';
|
||||||
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
|
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 { UsageInsightsCard } from '@/components/analytics/usage-insights-card';
|
||||||
import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight, Lightbulb } from 'lucide-react';
|
import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight, Lightbulb } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -329,7 +329,7 @@ export function AnalyticsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* CLIProxy Stats - 2/10 width */}
|
{/* CLIProxy Stats - 2/10 width */}
|
||||||
<ClipproxyStatsCard className="lg:col-span-2" />
|
<CliproxyStatsCard className="lg:col-span-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model Details Popover - positioned at cursor */}
|
{/* Model Details Popover - positioned at cursor */}
|
||||||
|
|||||||
+493
-167
@@ -1,32 +1,170 @@
|
|||||||
/**
|
/**
|
||||||
* CLIProxy Page
|
* CLIProxy Page - Master-Detail Layout
|
||||||
* Phase 03: REST API Routes & CRUD
|
* Left sidebar: Provider navigation + Quick actions
|
||||||
* Phase 06: Multi-Account Management
|
* 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 { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Plus, Check, X, User, ChevronDown, Star, Trash2, Sparkles } from 'lucide-react';
|
import {
|
||||||
import { CliproxyTable } from '@/components/cliproxy-table';
|
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 { QuickSetupWizard } from '@/components/quick-setup-wizard';
|
||||||
import { AddAccountDialog } from '@/components/add-account-dialog';
|
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 {
|
import {
|
||||||
useCliproxy,
|
useCliproxy,
|
||||||
useCliproxyAuth,
|
useCliproxyAuth,
|
||||||
useSetDefaultAccount,
|
useSetDefaultAccount,
|
||||||
useRemoveAccount,
|
useRemoveAccount,
|
||||||
|
useCliproxyModels,
|
||||||
|
useUpdateModel,
|
||||||
} from '@/hooks/use-cliproxy';
|
} from '@/hooks/use-cliproxy';
|
||||||
|
import { useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||||
import type { OAuthAccount, AuthStatus } from '@/lib/api-client';
|
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({
|
function AccountBadge({
|
||||||
account,
|
account,
|
||||||
onSetDefault,
|
onSetDefault,
|
||||||
@@ -39,122 +177,239 @@ function AccountBadge({
|
|||||||
isRemoving: boolean;
|
isRemoving: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<div
|
||||||
<DropdownMenuTrigger asChild>
|
className={cn(
|
||||||
<button
|
'flex items-center justify-between p-3 rounded-lg border transition-colors',
|
||||||
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/5' : 'border-border hover:bg-muted/30'
|
||||||
account.isDefault
|
)}
|
||||||
? 'border-primary/30 bg-primary/10 text-primary font-medium'
|
>
|
||||||
: 'border-muted bg-muted/40'
|
<div className="flex items-center gap-3">
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<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
|
<div
|
||||||
className={`flex items-center justify-center w-8 h-8 rounded-full ${
|
className={cn(
|
||||||
status.authenticated
|
'flex items-center justify-center w-8 h-8 rounded-full',
|
||||||
? 'bg-green-500/10 text-green-600 dark:text-green-400'
|
account.isDefault ? 'bg-primary/10' : 'bg-muted'
|
||||||
: 'bg-muted text-muted-foreground'
|
)}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{status.authenticated ? <Check className="w-5 h-5" /> : <X className="w-5 h-5" />}
|
<User className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{status.displayName}
|
<span className="font-medium text-sm">{account.email || account.id}</span>
|
||||||
{accounts.length > 0 && (
|
{account.isDefault && (
|
||||||
<Badge variant="secondary" className="text-[10px] h-5 px-1.5 font-normal">
|
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
|
||||||
{accounts.length}
|
<Star className="w-2.5 h-2.5 fill-current" />
|
||||||
|
Default
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex items-center gap-2 flex-wrap">
|
<DropdownMenu>
|
||||||
{status.authenticated && accounts.length > 0 ? (
|
<DropdownMenuTrigger asChild>
|
||||||
accounts.map((account) => (
|
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||||
<AccountBadge
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
key={account.id}
|
</Button>
|
||||||
account={account}
|
</DropdownMenuTrigger>
|
||||||
onSetDefault={() =>
|
<DropdownMenuContent align="end">
|
||||||
setDefaultMutation.mutate({
|
{!account.isDefault && (
|
||||||
provider: status.provider,
|
<DropdownMenuItem onClick={onSetDefault}>
|
||||||
accountId: account.id,
|
<Star className="w-4 h-4 mr-2" />
|
||||||
})
|
Set as default
|
||||||
}
|
</DropdownMenuItem>
|
||||||
onRemove={() =>
|
)}
|
||||||
removeMutation.mutate({
|
<DropdownMenuItem
|
||||||
provider: status.provider,
|
className="text-destructive focus:text-destructive"
|
||||||
accountId: account.id,
|
onClick={onRemove}
|
||||||
})
|
disabled={isRemoving}
|
||||||
}
|
>
|
||||||
isRemoving={removeMutation.isPending}
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
/>
|
{isRemoving ? 'Removing...' : 'Remove account'}
|
||||||
))
|
</DropdownMenuItem>
|
||||||
) : (
|
</DropdownMenuContent>
|
||||||
<div className="text-sm text-muted-foreground italic">
|
</DropdownMenu>
|
||||||
{status.authenticated
|
</div>
|
||||||
? 'Authenticated (No specific accounts tracked)'
|
);
|
||||||
: 'Not authenticated'}
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={onAddAccount} className="gap-2">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add Account
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{/* Model Preference */}
|
||||||
{/* Show Add Account button for all - opens dialog with instructions */}
|
<Card className="mb-4">
|
||||||
<Button variant="outline" size="sm" className="h-7 text-xs gap-1" onClick={onAddAccount}>
|
<CardHeader className="py-3 px-4">
|
||||||
<Plus className="w-3 h-3" />
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
Add Account
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,89 +417,160 @@ function ProviderRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CliproxyPage() {
|
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 [wizardOpen, setWizardOpen] = useState(false);
|
||||||
const [addAccountProvider, setAddAccountProvider] = useState<{
|
const [addAccountProvider, setAddAccountProvider] = useState<{
|
||||||
provider: string;
|
provider: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const { data, isLoading } = useCliproxy();
|
|
||||||
const { data: authData, isLoading: authLoading } = useCliproxyAuth();
|
// Auto-select first provider if none selected
|
||||||
const setDefaultMutation = useSetDefaultAccount();
|
const providers = authData?.authStatus || [];
|
||||||
const removeMutation = useRemoveAccount();
|
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 (
|
return (
|
||||||
<div className="p-6 max-w-6xl mx-auto space-y-8">
|
<div className="h-[calc(100vh-100px)] flex">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
{/* Left Sidebar */}
|
||||||
<div>
|
<div className="w-64 border-r flex flex-col bg-muted/30">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">CLIProxy</h1>
|
{/* Header */}
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<div className="p-4 border-b bg-background">
|
||||||
Manage OAuth-based provider variants and multi-account configurations
|
<div className="flex items-center justify-between mb-3">
|
||||||
</p>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<Zap className="w-5 h-5 text-primary" />
|
||||||
<Button onClick={() => setWizardOpen(true)}>
|
<h1 className="font-semibold">CLIProxy</h1>
|
||||||
<Sparkles className="w-4 h-4 mr-2" />
|
</div>
|
||||||
Quick Setup
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
</div>
|
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 */}
|
<Button
|
||||||
<ClipproxyStatsOverview />
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
{/* Built-in Profiles with Account Management */}
|
className="w-full gap-2"
|
||||||
<div className="space-y-4">
|
onClick={() => setWizardOpen(true)}
|
||||||
<div>
|
>
|
||||||
<h2 className="text-lg font-semibold tracking-tight">Built-in Providers</h2>
|
<Sparkles className="w-4 h-4" />
|
||||||
<p className="text-sm text-muted-foreground">
|
Quick Setup
|
||||||
Manage authentication status and accounts for built-in providers.
|
</Button>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="py-0 gap-0">
|
{/* Providers List */}
|
||||||
<CardContent className="p-0">
|
<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 ? (
|
{authLoading ? (
|
||||||
<div className="p-8 text-center text-muted-foreground">
|
<div className="space-y-2 px-2">
|
||||||
Loading authentication status...
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-14 w-full rounded-lg" />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col">
|
<div className="space-y-1">
|
||||||
{authData?.authStatus.map((status) => (
|
{providers.map((status) => (
|
||||||
<ProviderRow
|
<ProviderSidebarItem
|
||||||
key={status.provider}
|
key={status.provider}
|
||||||
status={status}
|
status={status}
|
||||||
setDefaultMutation={setDefaultMutation}
|
isSelected={effectiveProvider === status.provider && viewMode === 'overview'}
|
||||||
removeMutation={removeMutation}
|
onSelect={() => {
|
||||||
onAddAccount={() =>
|
setSelectedProvider(status.provider);
|
||||||
setAddAccountProvider({
|
setViewMode('overview');
|
||||||
provider: status.provider,
|
}}
|
||||||
displayName: status.displayName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Custom Variants */}
|
<Separator className="my-2" />
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
{/* Quick Actions */}
|
||||||
<div>
|
<div className="p-2">
|
||||||
<h2 className="text-lg font-semibold tracking-tight">Custom Variants</h2>
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
Tools
|
||||||
Create custom aliases for providers with specific accounts.
|
</div>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{/* Right Panel */}
|
||||||
<div className="text-muted-foreground">Loading variants...</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{/* Dialogs */}
|
||||||
<QuickSetupWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
|
<QuickSetupWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
|
||||||
<AddAccountDialog
|
<AddAccountDialog
|
||||||
open={addAccountProvider !== null}
|
open={addAccountProvider !== null}
|
||||||
|
|||||||
Reference in new issue
Block a user