mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 08:15:17 +00:00
Merge pull request #144 from kaitranntt/kai/fix/cliproxy-update-notification
fix(cliproxy): prevent misleading update message when proxy is running
This commit is contained in:
5 files changed
+250
-39
No files matched your search
@@ -19,7 +19,8 @@ import * as crypto from 'crypto';
|
||||
import * as zlib from 'zlib';
|
||||
import { ProgressIndicator } from '../utils/progress-indicator';
|
||||
import { ok, info } from '../utils/ui';
|
||||
import { getBinDir, getCliproxyDir } from './config-generator';
|
||||
import { getBinDir, getCliproxyDir, CLIPROXY_DEFAULT_PORT } from './config-generator';
|
||||
import { isCliproxyRunning } from './stats-fetcher';
|
||||
import {
|
||||
BinaryInfo,
|
||||
BinaryManagerConfig,
|
||||
@@ -58,6 +59,7 @@ interface UpdateCheckResult {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
fromCache: boolean;
|
||||
checkedAt: number; // Unix timestamp of last check
|
||||
}
|
||||
|
||||
/** Default configuration */
|
||||
@@ -103,17 +105,31 @@ export class BinaryManager {
|
||||
try {
|
||||
const updateResult = await this.checkForUpdates();
|
||||
if (updateResult.hasUpdate) {
|
||||
console.log(
|
||||
info(
|
||||
`CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}`
|
||||
)
|
||||
);
|
||||
console.log(info('Updating CLIProxyAPI...'));
|
||||
// Check if CLIProxyAPI is currently running - can't update while running
|
||||
const proxyRunning = await isCliproxyRunning(CLIPROXY_DEFAULT_PORT);
|
||||
if (proxyRunning) {
|
||||
// Proxy is running - can't update, just notify user
|
||||
console.log(
|
||||
info(
|
||||
`CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}`
|
||||
)
|
||||
);
|
||||
console.log(info('Run "ccs cliproxy stop" then restart to apply update'));
|
||||
this.log('Skipping update: CLIProxyAPI is currently running');
|
||||
} else {
|
||||
// Proxy not running - safe to update
|
||||
console.log(
|
||||
info(
|
||||
`CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}`
|
||||
)
|
||||
);
|
||||
console.log(info('Updating CLIProxyAPI...'));
|
||||
|
||||
// Delete old binary and download new version
|
||||
this.deleteBinary();
|
||||
this.config.version = updateResult.latestVersion;
|
||||
await this.downloadAndInstall();
|
||||
// Delete old binary and download new version
|
||||
this.deleteBinary();
|
||||
this.config.version = updateResult.latestVersion;
|
||||
await this.downloadAndInstall();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silent fail - don't block startup if update check fails
|
||||
@@ -157,19 +173,21 @@ export class BinaryManager {
|
||||
const currentVersion = this.getInstalledVersion();
|
||||
|
||||
// Try cache first
|
||||
const cachedVersion = this.getCachedLatestVersion();
|
||||
if (cachedVersion) {
|
||||
this.log(`Using cached version: ${cachedVersion}`);
|
||||
const cache = this.getVersionCache();
|
||||
if (cache) {
|
||||
this.log(`Using cached version: ${cache.latestVersion}`);
|
||||
return {
|
||||
hasUpdate: this.isNewerVersion(cachedVersion, currentVersion),
|
||||
hasUpdate: this.isNewerVersion(cache.latestVersion, currentVersion),
|
||||
currentVersion,
|
||||
latestVersion: cachedVersion,
|
||||
latestVersion: cache.latestVersion,
|
||||
fromCache: true,
|
||||
checkedAt: cache.checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch from GitHub API
|
||||
const latestVersion = await this.fetchLatestVersion();
|
||||
const now = Date.now();
|
||||
this.cacheLatestVersion(latestVersion);
|
||||
|
||||
return {
|
||||
@@ -177,6 +195,7 @@ export class BinaryManager {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
fromCache: false,
|
||||
checkedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -272,9 +291,9 @@ export class BinaryManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached latest version if still valid
|
||||
* Get version cache data if still valid
|
||||
*/
|
||||
private getCachedLatestVersion(): string | null {
|
||||
private getVersionCache(): VersionCache | null {
|
||||
const cachePath = this.getVersionCachePath();
|
||||
if (!fs.existsSync(cachePath)) {
|
||||
return null;
|
||||
@@ -286,7 +305,7 @@ export class BinaryManager {
|
||||
|
||||
// Check if cache is still valid
|
||||
if (Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) {
|
||||
return cache.latestVersion;
|
||||
return cache;
|
||||
}
|
||||
|
||||
// Cache expired
|
||||
@@ -980,6 +999,24 @@ export async function fetchLatestCliproxyVersion(): Promise<string> {
|
||||
return result.latestVersion;
|
||||
}
|
||||
|
||||
/** Update check result for API response */
|
||||
export interface CliproxyUpdateCheckResult {
|
||||
hasUpdate: boolean;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
fromCache: boolean;
|
||||
checkedAt: number; // Unix timestamp of last check
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for CLIProxyAPI binary updates
|
||||
* @returns Update check result with version info
|
||||
*/
|
||||
export async function checkCliproxyUpdate(): Promise<CliproxyUpdateCheckResult> {
|
||||
const manager = new BinaryManager();
|
||||
return manager.checkForUpdates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to version pin file
|
||||
* @returns Absolute path to .version-pin file
|
||||
|
||||
@@ -44,8 +44,9 @@ import {
|
||||
} from '../cliproxy/account-manager';
|
||||
import type { CLIProxyProvider } from '../cliproxy/types';
|
||||
import { getClaudeEnvVars } from '../cliproxy/config-generator';
|
||||
import { getProxyStatus as getProxyProcessStatus } from '../cliproxy/session-tracker';
|
||||
import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../cliproxy/session-tracker';
|
||||
import { ensureCliproxyService } from '../cliproxy/service-manager';
|
||||
import { checkCliproxyUpdate } from '../cliproxy/binary-manager';
|
||||
// Unified config imports
|
||||
import {
|
||||
hasUnifiedConfig,
|
||||
@@ -1389,6 +1390,32 @@ apiRoutes.post('/cliproxy/proxy-start', async (_req: Request, res: Response): Pr
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cliproxy/proxy-stop - Stop the CLIProxy service
|
||||
* Returns: { stopped, pid?, sessionCount?, error? }
|
||||
*/
|
||||
apiRoutes.post('/cliproxy/proxy-stop', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const result = stopProxy();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/update-check - Check for CLIProxyAPI binary updates
|
||||
* Returns: { hasUpdate, currentVersion, latestVersion, fromCache }
|
||||
*/
|
||||
apiRoutes.get('/cliproxy/update-check', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const result = await checkCliproxyUpdate();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/models - Get available models from CLIProxyAPI
|
||||
* Returns: { models: CliproxyModel[], byCategory: Record<string, CliproxyModel[]>, totalCount: number }
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
/**
|
||||
* Proxy Status Widget
|
||||
*
|
||||
* Displays CLIProxy process status with start button for recovery.
|
||||
* Shows: running state, port, session count, uptime.
|
||||
* Displays CLIProxy process status with start/stop/restart controls.
|
||||
* Shows: running state, port, session count, uptime, update availability.
|
||||
*/
|
||||
|
||||
import { Activity, Power, RefreshCw, Clock, Users } from 'lucide-react';
|
||||
import { Activity, Power, RefreshCw, Clock, Users, Square, RotateCw, ArrowUp } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useProxyStatus, useStartProxy } from '@/hooks/use-cliproxy';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
useProxyStatus,
|
||||
useStartProxy,
|
||||
useStopProxy,
|
||||
useCliproxyUpdateCheck,
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function formatUptime(startedAt?: string): string {
|
||||
@@ -25,11 +31,34 @@ function formatUptime(startedAt?: string): string {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function formatTimeAgo(timestamp?: number): string {
|
||||
if (!timestamp) return '';
|
||||
const diff = Date.now() - timestamp;
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
export function ProxyStatusWidget() {
|
||||
const { data: status, isLoading } = useProxyStatus();
|
||||
const { data: updateCheck } = useCliproxyUpdateCheck();
|
||||
const startProxy = useStartProxy();
|
||||
const stopProxy = useStopProxy();
|
||||
|
||||
const isRunning = status?.running ?? false;
|
||||
const isActioning = startProxy.isPending || stopProxy.isPending;
|
||||
const hasUpdate = updateCheck?.hasUpdate ?? false;
|
||||
|
||||
// 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();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -47,6 +76,16 @@ export function ProxyStatusWidget() {
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">CLIProxy Service</span>
|
||||
{hasUpdate && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-4 px-1.5 gap-0.5 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
title={`Update: v${updateCheck?.currentVersion} -> v${updateCheck?.latestVersion}`}
|
||||
>
|
||||
<ArrowUp className="w-2.5 h-2.5" />
|
||||
Update
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -61,21 +100,65 @@ export function ProxyStatusWidget() {
|
||||
</div>
|
||||
|
||||
{isRunning && status ? (
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">Port {status.port}</span>
|
||||
{status.sessionCount !== undefined && status.sessionCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{status.startedAt && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatUptime(status.startedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">Port {status.port}</span>
|
||||
{status.sessionCount !== undefined && status.sessionCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{status.startedAt && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatUptime(status.startedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Control buttons when running */}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Button
|
||||
variant={hasUpdate ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 text-xs gap-1 flex-1',
|
||||
hasUpdate && 'bg-amber-600 hover:bg-amber-700 text-white'
|
||||
)}
|
||||
onClick={handleRestart}
|
||||
disabled={isActioning}
|
||||
title={
|
||||
hasUpdate
|
||||
? `Restart to update: v${updateCheck?.currentVersion} -> v${updateCheck?.latestVersion}`
|
||||
: 'Restart CLIProxy service'
|
||||
}
|
||||
>
|
||||
{isActioning ? (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
) : hasUpdate ? (
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
) : (
|
||||
<RotateCw className="w-3 h-3" />
|
||||
)}
|
||||
{hasUpdate ? 'Update' : 'Restart'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/30"
|
||||
onClick={() => stopProxy.mutate()}
|
||||
disabled={isActioning}
|
||||
title="Stop CLIProxy service"
|
||||
>
|
||||
{stopProxy.isPending ? (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Square className="w-3 h-3" />
|
||||
)}
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">Not running</span>
|
||||
@@ -95,6 +178,18 @@ export function ProxyStatusWidget() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Version sync indicator */}
|
||||
{updateCheck?.currentVersion && (
|
||||
<div className="mt-2 pt-2 border-t border-muted flex items-center justify-between text-[10px] text-muted-foreground/70">
|
||||
<span>v{updateCheck.currentVersion}</span>
|
||||
{updateCheck.checkedAt && (
|
||||
<span title={new Date(updateCheck.checkedAt).toLocaleString()}>
|
||||
Synced {formatTimeAgo(updateCheck.checkedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -240,3 +240,36 @@ export function useStartProxy() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useStopProxy() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => api.cliproxy.proxyStop(),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['proxy-status'] });
|
||||
if (data.stopped) {
|
||||
toast.success(
|
||||
`CLIProxy stopped${data.sessionCount ? ` (${data.sessionCount} session(s) disconnected)` : ''}`
|
||||
);
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to stop CLIProxy');
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Update Check ====================
|
||||
|
||||
export function useCliproxyUpdateCheck() {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy-update-check'],
|
||||
queryFn: () => api.cliproxy.updateCheck(),
|
||||
staleTime: 60 * 60 * 1000, // 1 hour (matches backend cache)
|
||||
refetchInterval: 60 * 60 * 1000, // Refresh every hour
|
||||
refetchOnWindowFocus: false, // Don't refresh on window focus (save API calls)
|
||||
});
|
||||
}
|
||||
@@ -182,6 +182,23 @@ export interface ProxyStartResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Result from stopping proxy service */
|
||||
export interface ProxyStopResult {
|
||||
stopped: boolean;
|
||||
pid?: number;
|
||||
sessionCount?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Result from checking for CLIProxyAPI updates */
|
||||
export interface CliproxyUpdateCheckResult {
|
||||
hasUpdate: boolean;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
fromCache: boolean;
|
||||
checkedAt: number; // Unix timestamp of last check
|
||||
}
|
||||
|
||||
// API
|
||||
export const api = {
|
||||
profiles: {
|
||||
@@ -216,6 +233,8 @@ export const api = {
|
||||
// Proxy process status and control
|
||||
proxyStatus: () => request<ProxyProcessStatus>('/cliproxy/proxy-status'),
|
||||
proxyStart: () => request<ProxyStartResult>('/cliproxy/proxy-start', { method: 'POST' }),
|
||||
proxyStop: () => request<ProxyStopResult>('/cliproxy/proxy-stop', { method: 'POST' }),
|
||||
updateCheck: () => request<CliproxyUpdateCheckResult>('/cliproxy/update-check'),
|
||||
|
||||
// Stats and models for Overview tab
|
||||
stats: () => request<{ usage: Record<string, unknown> }>('/cliproxy/usage'),
|
||||
|
||||
Reference in new issue
Block a user