diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 51a33d45..7e52e81a 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -8,7 +8,7 @@ import { info, warn } from '../utils/ui'; import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config-generator'; import { BinaryInfo, BinaryManagerConfig } from './types'; -import { CLIPROXY_FALLBACK_VERSION } from './platform-detector'; +import { CLIPROXY_FALLBACK_VERSION, CLIPROXY_MAX_STABLE_VERSION } from './platform-detector'; import { isProxyRunning, stopProxy } from './services/proxy-lifecycle-service'; import { waitForPortFree } from '../utils/port-utils'; import { @@ -151,11 +151,29 @@ export interface CliproxyUpdateCheckResult { latestVersion: string; fromCache: boolean; checkedAt: number; + // Stability fields + isStable: boolean; + maxStableVersion: string; + stabilityMessage?: string; } /** Check for CLIProxyAPI binary updates */ export async function checkCliproxyUpdate(): Promise { - return new BinaryManager().checkForUpdates(); + const result = await new BinaryManager().checkForUpdates(); + + // Import isNewerVersion for stability check + const { isNewerVersion } = await import('./binary/version-checker'); + const isStable = !isNewerVersion(result.currentVersion, CLIPROXY_MAX_STABLE_VERSION); + const stabilityMessage = isStable + ? undefined + : `v${result.currentVersion} has known stability issues. Max stable: v${CLIPROXY_MAX_STABLE_VERSION}`; + + return { + ...result, + isStable, + maxStableVersion: CLIPROXY_MAX_STABLE_VERSION, + stabilityMessage, + }; } // Re-export version pin functions diff --git a/src/cliproxy/binary/lifecycle.ts b/src/cliproxy/binary/lifecycle.ts index 30156777..51867a4e 100644 --- a/src/cliproxy/binary/lifecycle.ts +++ b/src/cliproxy/binary/lifecycle.ts @@ -7,22 +7,68 @@ import * as fs from 'fs'; import { BinaryManagerConfig } from '../types'; import { checkForUpdates, fetchLatestVersion, isNewerVersion } from './version-checker'; import { downloadAndInstall, deleteBinary, getBinaryPath } from './installer'; -import { info } from '../../utils/ui'; +import { info, warn } from '../../utils/ui'; import { isCliproxyRunning } from '../stats-fetcher'; import { CLIPROXY_DEFAULT_PORT } from '../config-generator'; +import { CLIPROXY_MAX_STABLE_VERSION } from '../platform-detector'; /** Log helper */ function log(message: string, verbose: boolean): void { if (verbose) console.error(`[cliproxy] ${message}`); } +/** + * Check if version is above max stable (known unstable) + */ +function isAboveMaxStable(version: string): boolean { + return isNewerVersion(version, CLIPROXY_MAX_STABLE_VERSION); +} + +/** + * Clamp version to max stable if newer versions are unstable + * Returns max stable version if input is empty/invalid + */ +function clampToMaxStable(version: string | undefined, verbose: boolean): string { + if (!version) { + log(`Empty version, using max stable ${CLIPROXY_MAX_STABLE_VERSION}`, verbose); + return CLIPROXY_MAX_STABLE_VERSION; + } + if (isAboveMaxStable(version)) { + log(`Clamping ${version} to max stable ${CLIPROXY_MAX_STABLE_VERSION}`, verbose); + return CLIPROXY_MAX_STABLE_VERSION; + } + return version; +} + /** Handle auto-update when binary exists */ async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): Promise { const updateResult = await checkForUpdates(config.binPath, config.version, verbose); + const currentVersion = updateResult.currentVersion; + const latestVersion = updateResult.latestVersion; + + // Check if user is on known unstable version - inform but don't force downgrade + if (isAboveMaxStable(currentVersion)) { + console.log( + warn( + `CLIProxy Plus v${currentVersion} has known stability issues. ` + + `Stable version: v${CLIPROXY_MAX_STABLE_VERSION}` + ) + ); + console.log(info('Run "ccs cliproxy install 80" to downgrade, or wait for upstream fix')); + } + if (!updateResult.hasUpdate) return; + // Clamp to max stable version + const targetVersion = clampToMaxStable(latestVersion, verbose); + if (!isNewerVersion(targetVersion, currentVersion)) { + log(`Already at max stable version ${currentVersion}`, verbose); + return; + } + const proxyRunning = await isCliproxyRunning(CLIPROXY_DEFAULT_PORT); - const updateMsg = `CLIProxy Plus update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}`; + const latestNote = isAboveMaxStable(latestVersion) ? ` (latest v${latestVersion} unstable)` : ''; + const updateMsg = `CLIProxy Plus update: v${currentVersion} -> v${targetVersion}${latestNote}`; if (proxyRunning) { console.log(info(updateMsg)); @@ -32,7 +78,7 @@ async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): console.log(info(updateMsg)); console.log(info('Updating CLIProxy Plus...')); deleteBinary(config.binPath, verbose); - config.version = updateResult.latestVersion; + config.version = targetVersion; await downloadAndInstall(config, verbose); } } @@ -70,12 +116,16 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise if (!config.forceVersion) { try { const latestVersion = await fetchLatestVersion(verbose); - if (latestVersion && isNewerVersion(latestVersion, config.version)) { - log(`Using latest version: ${latestVersion} (instead of ${config.version})`, verbose); - config.version = latestVersion; + const targetVersion = clampToMaxStable(latestVersion, verbose); + if (targetVersion && isNewerVersion(targetVersion, config.version)) { + log(`Using version: ${targetVersion} (instead of ${config.version})`, verbose); + config.version = targetVersion; } } catch { - log(`Using pinned version: ${config.version}`, verbose); + // API failed - use fallback but still clamp to max stable + const fallbackVersion = clampToMaxStable(config.version, verbose); + config.version = fallbackVersion; + log(`Using fallback version: ${fallbackVersion}`, verbose); } } else { log(`Force version mode: using specified version ${config.version}`, verbose); diff --git a/src/cliproxy/binary/types.ts b/src/cliproxy/binary/types.ts index 133faaf4..12eda079 100644 --- a/src/cliproxy/binary/types.ts +++ b/src/cliproxy/binary/types.ts @@ -27,3 +27,20 @@ export const VERSION_PIN_FILE = '.version-pin'; /** GitHub API URL for latest release (CLIProxyAPIPlus fork with Kiro + Copilot support) */ export const GITHUB_API_LATEST_RELEASE = 'https://api.github.com/repos/router-for-me/CLIProxyAPIPlus/releases/latest'; + +/** GitHub API URL for all releases */ +export const GITHUB_API_ALL_RELEASES = + 'https://api.github.com/repos/router-for-me/CLIProxyAPIPlus/releases'; + +/** Version list cache structure */ +export interface VersionListCache { + versions: string[]; + latestStable: string; + latest: string; + checkedAt: number; +} + +/** Version list result from API */ +export interface VersionListResult extends VersionListCache { + fromCache: boolean; +} diff --git a/src/cliproxy/binary/version-cache.ts b/src/cliproxy/binary/version-cache.ts index d77cf7cf..63a19a56 100644 --- a/src/cliproxy/binary/version-cache.ts +++ b/src/cliproxy/binary/version-cache.ts @@ -6,7 +6,12 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCliproxyDir, getBinDir } from '../config-generator'; -import { VersionCache, VERSION_CACHE_DURATION_MS, VERSION_PIN_FILE } from './types'; +import { + VersionCache, + VERSION_CACHE_DURATION_MS, + VERSION_PIN_FILE, + VersionListCache, +} from './types'; /** * Get path to version cache file @@ -140,3 +145,52 @@ export function clearPinnedVersion(): void { export function isVersionPinned(): boolean { return getPinnedVersion() !== null; } + +// ==================== Version List Cache ==================== + +const VERSION_LIST_CACHE_FILE = '.version-list-cache.json'; + +/** + * Get path to version list cache file + */ +export function getVersionListCachePath(): string { + return path.join(getCliproxyDir(), VERSION_LIST_CACHE_FILE); +} + +/** + * Read version list cache if still valid + */ +export function readVersionListCache(): VersionListCache | null { + const cachePath = getVersionListCachePath(); + if (!fs.existsSync(cachePath)) { + return null; + } + + try { + const content = fs.readFileSync(cachePath, 'utf8'); + const cache: VersionListCache = JSON.parse(content); + + // Check if cache is still valid (1 hour) + if (Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) { + return cache; + } + + return null; + } catch { + return null; + } +} + +/** + * Write version list to cache + */ +export function writeVersionListCache(cache: VersionListCache): void { + const cachePath = getVersionListCachePath(); + + try { + fs.mkdirSync(path.dirname(cachePath), { recursive: true }); + fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf8'); + } catch { + // Silent fail - caching is optional + } +} diff --git a/src/cliproxy/binary/version-checker.ts b/src/cliproxy/binary/version-checker.ts index 6108bd82..8ebfeb4d 100644 --- a/src/cliproxy/binary/version-checker.ts +++ b/src/cliproxy/binary/version-checker.ts @@ -4,8 +4,20 @@ */ import { fetchJson } from './downloader'; -import { readVersionCache, writeVersionCache, readInstalledVersion } from './version-cache'; -import { UpdateCheckResult, GITHUB_API_LATEST_RELEASE } from './types'; +import { + readVersionCache, + writeVersionCache, + readInstalledVersion, + readVersionListCache, + writeVersionListCache, +} from './version-cache'; +import { + UpdateCheckResult, + GITHUB_API_LATEST_RELEASE, + GITHUB_API_ALL_RELEASES, + VersionListResult, +} from './types'; +import { CLIPROXY_MAX_STABLE_VERSION } from '../platform-detector'; /** * Compare semver versions (true if latest > current) @@ -85,3 +97,45 @@ export async function checkForUpdates( checkedAt: now, }; } + +/** + * Fetch all available versions from GitHub releases + * Caches result for 1 hour to avoid rate limiting + */ +export async function fetchAllVersions(verbose = false): Promise { + // Try cache first + const cache = readVersionListCache(); + if (cache) { + if (verbose) { + console.error(`[cliproxy] Using cached version list (${cache.versions.length} versions)`); + } + return { ...cache, fromCache: true }; + } + + // Fetch from GitHub API + const response = await fetchJson(GITHUB_API_ALL_RELEASES, verbose); + + // Extract and normalize versions + const releases = response as unknown as Array<{ tag_name: string }>; + const versions = releases + .map((r) => r.tag_name.replace(/^v/, '')) + .filter((v) => /^\d+\.\d+\.\d+(-\d+)?$/.test(v)); // Valid semver only + + const latest = versions[0] || ''; + + // Find latest stable (not newer than max stable) + const latestStable = + versions.find((v) => !isNewerVersion(v, CLIPROXY_MAX_STABLE_VERSION)) || + CLIPROXY_MAX_STABLE_VERSION; + + const result: VersionListResult = { + versions, + latestStable, + latest, + fromCache: false, + checkedAt: Date.now(), + }; + + writeVersionListCache(result); + return result; +} diff --git a/src/cliproxy/platform-detector.ts b/src/cliproxy/platform-detector.ts index 1d8333b2..bbe76688 100644 --- a/src/cliproxy/platform-detector.ts +++ b/src/cliproxy/platform-detector.ts @@ -14,6 +14,13 @@ import { PlatformInfo, SupportedOS, SupportedArch, ArchiveExtension } from './ty */ export const CLIPROXY_FALLBACK_VERSION = '6.6.40-0'; +/** + * Maximum stable version cap - prevents auto-update to known unstable releases + * v81+ has context cancellation bugs causing intermittent 500 errors + * See: https://github.com/kaitranntt/ccs/issues/269 + */ +export const CLIPROXY_MAX_STABLE_VERSION = '6.6.80-0'; + /** @deprecated Use CLIPROXY_FALLBACK_VERSION instead */ export const CLIPROXY_VERSION = CLIPROXY_FALLBACK_VERSION; diff --git a/src/web-server/health/cliproxy-checks.ts b/src/web-server/health/cliproxy-checks.ts index 5c1ee2f1..fb41c7af 100644 --- a/src/web-server/health/cliproxy-checks.ts +++ b/src/web-server/health/cliproxy-checks.ts @@ -15,6 +15,8 @@ import { } from '../../cliproxy'; import { getPortProcess, isCLIProxyProcess } from '../../utils/port-utils'; import type { HealthCheck } from './types'; +import { CLIPROXY_MAX_STABLE_VERSION } from '../../cliproxy/platform-detector'; +import { isNewerVersion } from '../../cliproxy/binary/version-checker'; /** * Check CLIProxy binary installation @@ -23,6 +25,21 @@ export function checkCliproxyBinary(): HealthCheck { if (isCLIProxyInstalled()) { const version = getInstalledCliproxyVersion(); const binaryPath = getCLIProxyPath(); + + // Check if version exceeds stable cap + const isUnstable = isNewerVersion(version, CLIPROXY_MAX_STABLE_VERSION); + + if (isUnstable) { + return { + id: 'cliproxy-binary', + name: 'CLIProxy Binary', + status: 'warning', + message: `v${version} (unstable)`, + details: binaryPath, + fix: `Downgrade: ccs cliproxy install ${CLIPROXY_MAX_STABLE_VERSION.replace(/-\d+$/, '')}`, + }; + } + return { id: 'cliproxy-binary', name: 'CLIProxy Binary', diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index ac11ca84..2c07ef99 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -21,7 +21,13 @@ import { } from '../../cliproxy/config-generator'; import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker'; import { ensureCliproxyService } from '../../cliproxy/service-manager'; -import { checkCliproxyUpdate } from '../../cliproxy/binary-manager'; +import { + checkCliproxyUpdate, + getInstalledCliproxyVersion, + installCliproxyVersion, +} from '../../cliproxy/binary-manager'; +import { fetchAllVersions, isNewerVersion } from '../../cliproxy/binary/version-checker'; +import { CLIPROXY_MAX_STABLE_VERSION } from '../../cliproxy/platform-detector'; const router = Router(); @@ -528,4 +534,102 @@ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): P } }); +// ==================== Version Management ==================== + +/** + * GET /api/cliproxy/versions - Get all available CLIProxyAPI versions + * Returns: { versions, latestStable, latest, currentVersion, maxStableVersion } + */ +router.get('/versions', async (_req: Request, res: Response): Promise => { + try { + const result = await fetchAllVersions(); + const currentVersion = getInstalledCliproxyVersion(); + + res.json({ + ...result, + currentVersion, + maxStableVersion: CLIPROXY_MAX_STABLE_VERSION, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cliproxy/install - Install specific CLIProxyAPI version + * Body: { version: string, force?: boolean } + * Returns: { success, requiresConfirmation?, message? } + */ +router.post('/install', async (req: Request, res: Response): Promise => { + try { + const { version, force } = req.body; + + if (!version || typeof version !== 'string') { + res.status(400).json({ error: 'Missing required field: version' }); + return; + } + + // Validate version format + if (!/^\d+\.\d+\.\d+(-\d+)?$/.test(version)) { + res.status(400).json({ error: 'Invalid version format. Expected: X.Y.Z or X.Y.Z-N' }); + return; + } + + // Check if version is unstable + const isUnstable = isNewerVersion(version, CLIPROXY_MAX_STABLE_VERSION); + + if (isUnstable && !force) { + res.json({ + success: false, + requiresConfirmation: true, + message: `Version ${version} is unstable (above max stable ${CLIPROXY_MAX_STABLE_VERSION}). Set force=true to proceed.`, + }); + return; + } + + // Stop proxy first if running + await stopProxy(); + + // Small delay to ensure port is released + await new Promise((r) => setTimeout(r, 500)); + + // Install the version + await installCliproxyVersion(version, true); + + res.json({ + success: true, + version, + isUnstable, + message: `Successfully installed CLIProxy Plus v${version}`, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cliproxy/restart - Restart CLIProxy without version change + * Returns: { success, port?, error? } + */ +router.post('/restart', async (_req: Request, res: Response): Promise => { + try { + // Stop proxy first + await stopProxy(); + + // Small delay to ensure port is released + await new Promise((r) => setTimeout(r, 500)); + + // Start proxy + const startResult = await ensureCliproxyService(); + + if (startResult.started || startResult.alreadyRunning) { + res.json({ success: true, port: startResult.port }); + } else { + res.json({ success: false, error: startResult.error || 'Failed to start proxy' }); + } + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + export default router; diff --git a/ui/src/components/cliproxy/cliproxy-header.tsx b/ui/src/components/cliproxy/cliproxy-header.tsx index a2d39178..956d37ca 100644 --- a/ui/src/components/cliproxy/cliproxy-header.tsx +++ b/ui/src/components/cliproxy/cliproxy-header.tsx @@ -6,11 +6,17 @@ import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { RefreshCw, Loader2 } from 'lucide-react'; +import { RefreshCw, Loader2, AlertTriangle } from 'lucide-react'; import { useCliproxyAuth } from '@/hooks/use-cliproxy'; import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow'; import { cn } from '@/lib/utils'; +interface VersionInfo { + currentVersion: string; + isStable: boolean; + stabilityMessage?: string; +} + interface LoginButtonProps { provider: string; displayName: string; @@ -110,6 +116,22 @@ export function CliproxyHeader({ const { data: authData } = useCliproxyAuth(); const { provider: authProvider, isAuthenticating, startAuth } = useCliproxyAuthFlow(); const lastUpdatedText = useRelativeTime(lastUpdated); + const [versionInfo, setVersionInfo] = useState(null); + + useEffect(() => { + fetch('/api/cliproxy/update-check') + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (data) { + setVersionInfo({ + currentVersion: data.currentVersion, + isStable: data.isStable, + stabilityMessage: data.stabilityMessage, + }); + } + }) + .catch(() => {}); // Silently fail + }, []); const providers = [ { id: 'claude', displayName: 'Claude' }, @@ -170,6 +192,21 @@ export function CliproxyHeader({ {isRunning ? 'Running' : 'Offline'} + {versionInfo && ( + + {!versionInfo.isStable && }v + {versionInfo.currentVersion} + + )} + {lastUpdatedText && ( {lastUpdatedText} )} diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index f0801c16..2417b6d6 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -4,8 +4,11 @@ * Displays CLIProxy process status with start/stop/restart controls. * Shows: running state, port, session count, uptime, update availability. * In remote mode: shows remote server info instead of local controls. + * + * Design: Two-state widget (collapsed/expanded) with icon-only control buttons. */ +import { useState } from 'react'; import { Activity, Power, @@ -15,10 +18,34 @@ import { Square, RotateCw, ArrowUp, + ArrowDown, Globe, + AlertTriangle, + Settings, + X, + Download, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useQuery } from '@tanstack/react-query'; import { api, type CliproxyServerConfig } from '@/lib/api-client'; import { @@ -26,9 +53,23 @@ import { useStartProxy, useStopProxy, useCliproxyUpdateCheck, + useCliproxyVersions, + useInstallVersion, + useRestartProxy, } from '@/hooks/use-cliproxy'; import { cn } from '@/lib/utils'; +/** Client-side semver comparison (true if a > b) */ +function isNewerVersionClient(a: string, b: string): boolean { + const aParts = a.replace(/-\d+$/, '').split('.').map(Number); + const bParts = b.replace(/-\d+$/, '').split('.').map(Number); + for (let i = 0; i < 3; i++) { + if ((aParts[i] || 0) > (bParts[i] || 0)) return true; + if ((aParts[i] || 0) < (bParts[i] || 0)) return false; + } + return false; +} + function formatUptime(startedAt?: string): string { if (!startedAt) return ''; const start = new Date(startedAt).getTime(); @@ -55,11 +96,69 @@ function formatTimeAgo(timestamp?: number): string { return `${hours}h ago`; } +/** Icon button with tooltip wrapper */ +function IconButton({ + icon: Icon, + tooltip, + onClick, + disabled, + isPending, + className, + variant = 'ghost', +}: { + icon: React.ElementType; + tooltip: string; + onClick: () => void; + disabled?: boolean; + isPending?: boolean; + className?: string; + variant?: 'ghost' | 'outline' | 'destructive-ghost'; +}) { + return ( + + + + + + {tooltip} + + + ); +} + export function ProxyStatusWidget() { const { data: status, isLoading } = useProxyStatus(); const { data: updateCheck } = useCliproxyUpdateCheck(); + const { data: versionsData, isLoading: versionsLoading } = useCliproxyVersions(); const startProxy = useStartProxy(); const stopProxy = useStopProxy(); + const restartProxy = useRestartProxy(); + const installVersion = useInstallVersion(); + + // Version picker state (expanded section) + const [isExpanded, setIsExpanded] = useState(false); + const [selectedVersion, setSelectedVersion] = useState(''); + + // Confirmation dialog state for unstable versions + const [showUnstableConfirm, setShowUnstableConfirm] = useState(false); + const [pendingInstallVersion, setPendingInstallVersion] = useState(null); // Fetch cliproxy_server config for remote mode detection const { data: cliproxyConfig } = useQuery({ @@ -73,8 +172,50 @@ export function ProxyStatusWidget() { const isRemoteMode = remoteConfig?.enabled && remoteConfig?.host; const isRunning = status?.running ?? false; - const isActioning = startProxy.isPending || stopProxy.isPending; + const isActioning = + startProxy.isPending || + stopProxy.isPending || + restartProxy.isPending || + installVersion.isPending; const hasUpdate = updateCheck?.hasUpdate ?? false; + const isUnstable = updateCheck?.isStable === false; + const currentVersion = updateCheck?.currentVersion; + + // Target version for update/downgrade badge + const targetVersion = isUnstable + ? updateCheck?.maxStableVersion || versionsData?.latestStable + : updateCheck?.latestVersion; + + // Handle version install (shows confirmation for unstable) + const handleInstallVersion = (version: string) => { + if (!version) return; + const maxStable = versionsData?.maxStableVersion || '6.6.80'; + const isVersionUnstable = isNewerVersionClient(version, maxStable); + + if (isVersionUnstable) { + // Show confirmation dialog for unstable versions + setPendingInstallVersion(version); + setShowUnstableConfirm(true); + return; + } + + // Install directly if stable + installVersion.mutate({ version }); + }; + + // Confirm unstable version install + const handleConfirmUnstableInstall = () => { + if (pendingInstallVersion) { + installVersion.mutate({ version: pendingInstallVersion, force: true }); + } + setShowUnstableConfirm(false); + setPendingInstallVersion(null); + }; + + const handleCancelUnstableInstall = () => { + setShowUnstableConfirm(false); + setPendingInstallVersion(null); + }; // Build remote display info const remoteDisplayHost = isRemoteMode @@ -87,14 +228,6 @@ export function ProxyStatusWidget() { })() : null; - // Restart = stop then start - const handleRestart = async () => { - await stopProxy.mutateAsync(); - // Small delay to ensure port is released - await new Promise((r) => setTimeout(r, 500)); - startProxy.mutate(); - }; - // Remote mode: show remote server info if (isRemoteMode) { return ( @@ -130,49 +263,98 @@ export function ProxyStatusWidget() { ); } - // Local mode: show original controls - + // Local mode: Two-state widget (collapsed/expanded) return ( -
-
-
-
+
+ {/* Header row: Status dot, title, icon buttons */} +
+
+ {/* Status indicator */} +
+ CLIProxy Plus +
+ + {/* Right side: icon buttons when running */} +
+ {isLoading ? ( + + ) : isRunning ? ( + <> + restartProxy.mutate()} + disabled={isActioning} + isPending={restartProxy.isPending} + /> + stopProxy.mutate()} + disabled={isActioning} + isPending={stopProxy.isPending} + variant="destructive-ghost" + /> + setIsExpanded(!isExpanded)} + className={isExpanded ? 'bg-muted' : undefined} + /> + + ) : ( + )} - /> - CLIProxy Plus - {hasUpdate && ( - v${updateCheck?.latestVersion}`} +
+
+ + {/* Version row: version + update badge */} + {currentVersion && ( +
+ - - Update - - )} -
+ v{currentVersion} + + {(hasUpdate || isUnstable) && targetVersion && ( + handleInstallVersion(targetVersion)} + title={`Click to ${isUnstable ? 'downgrade' : 'update'}`} + > + {isUnstable ? ( + + ) : ( + + )} + {targetVersion} + + )} +
+ )} -
- {isLoading ? ( - - ) : isRunning ? ( - - ) : ( - - )} -
-
- - {isRunning && status ? ( - <> + {/* Stats row when running */} + {isRunning && status && (
Port {status.port} {status.sessionCount !== undefined && status.sessionCount > 0 && ( @@ -188,81 +370,138 @@ export function ProxyStatusWidget() { )}
- {/* Control buttons when running */} -
- +
+ + {/* Stability warning for selected version */} + {selectedVersion && + versionsData?.maxStableVersion && + isNewerVersionClient(selectedVersion, versionsData.maxStableVersion) && ( +
+ + Versions above {versionsData.maxStableVersion} have known issues +
+ )} + + {/* Sync time */} + {updateCheck?.checkedAt && ( +
+ Last checked {formatTimeAgo(updateCheck.checkedAt)} +
)} - onClick={handleRestart} - disabled={isActioning} - title={ - hasUpdate - ? `Restart to update: v${updateCheck?.currentVersion} -> v${updateCheck?.latestVersion}` - : 'Restart CLIProxy service' - } - > - {isActioning ? ( - - ) : hasUpdate ? ( - - ) : ( - - )} - {hasUpdate ? 'Update' : 'Restart'} - + + + )} + + {/* Not running state */} + {!isRunning && ( +
+ Not running
- - ) : ( -
- Not running - -
- )} + )} - {/* Version sync indicator */} - {updateCheck?.currentVersion && ( -
- v{updateCheck.currentVersion} - {updateCheck.checkedAt && ( - - Synced {formatTimeAgo(updateCheck.checkedAt)} - - )} -
- )} -
+ {/* Unstable Version Confirmation Dialog */} + + + + + + Install Unstable Version? + + +

+ You are about to install v{pendingInstallVersion}, which is above + the maximum stable version{' '} + v{versionsData?.maxStableVersion || '6.6.80'}. +

+

+ This version has known stability issues and may cause unexpected behavior. +

+

Are you sure you want to proceed?

+
+
+ + Cancel + + Install Anyway + + +
+
+
+ ); } diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index d4b6ec9c..13e8efee 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -304,3 +304,59 @@ export function useCliproxyUpdateCheck() { refetchOnWindowFocus: false, // Don't refresh on window focus (save API calls) }); } + +// ==================== Version Management ==================== + +export function useCliproxyVersions() { + return useQuery({ + queryKey: ['cliproxy-versions'], + queryFn: () => api.cliproxy.versions(), + staleTime: 60 * 60 * 1000, // 1 hour (matches backend cache) + refetchOnWindowFocus: false, + }); +} + +export function useInstallVersion() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ version, force }: { version: string; force?: boolean }) => + api.cliproxy.install(version, force), + onSuccess: (data) => { + if (data.requiresConfirmation) { + // Don't show toast - let caller handle confirmation dialog + return; + } + queryClient.invalidateQueries({ queryKey: ['cliproxy-versions'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-update-check'] }); + queryClient.invalidateQueries({ queryKey: ['proxy-status'] }); + if (data.success) { + toast.success(data.message || `Installed v${data.version}`); + } else { + toast.error(data.error || 'Installation failed'); + } + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +export function useRestartProxy() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => api.cliproxy.restart(), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['proxy-status'] }); + if (data.success) { + toast.success(`Proxy restarted on port ${data.port}`); + } else { + toast.error(data.error || 'Restart failed'); + } + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index a2cf98a5..aae75b09 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -259,6 +259,37 @@ export interface CliproxyUpdateCheckResult { latestVersion: string; fromCache: boolean; checkedAt: number; // Unix timestamp of last check + isStable: boolean; // Whether current version is at or below max stable + maxStableVersion: string; // Maximum stable version (e.g., "6.6.80") + stabilityMessage?: string; // Warning message if running unstable version +} + +/** Available versions list from GitHub releases */ +export interface CliproxyVersionsResponse { + versions: string[]; + latestStable: string; + latest: string; + currentVersion: string; + maxStableVersion: string; + fromCache: boolean; + checkedAt: number; +} + +/** Result from installing a specific version */ +export interface CliproxyInstallResult { + success: boolean; + version?: string; + isUnstable?: boolean; + requiresConfirmation?: boolean; + message?: string; + error?: string; +} + +/** Result from restarting the proxy */ +export interface CliproxyRestartResult { + success: boolean; + port?: number; + error?: string; } // API @@ -301,6 +332,15 @@ export const api = { proxyStop: () => request('/cliproxy/proxy-stop', { method: 'POST' }), updateCheck: () => request('/cliproxy/update-check'), + // Version management + versions: () => request('/cliproxy/versions'), + install: (version: string, force?: boolean) => + request('/cliproxy/install', { + method: 'POST', + body: JSON.stringify({ version, force }), + }), + restart: () => request('/cliproxy/restart', { method: 'POST' }), + // Stats and models for Overview tab stats: () => request<{ usage: Record }>('/cliproxy/usage'), models: () => request('/cliproxy/models'),