mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 06:20:04 +00:00
feat(cliproxy): add version management UI with install/restart controls
Implements comprehensive version management for CLIProxyAPI Plus: Backend APIs: - GET /versions: fetch available versions from GitHub releases - POST /install: install specific version with force flag for unstable - POST /restart: restart proxy without version change Frontend: - 4-button layout: Restart, Update/Downgrade, Stop, Settings gear - Collapsible version picker with dropdown + manual input - Confirmation dialog for unstable versions (>6.6.80) - Progressive disclosure (version picker hidden by default) Ref: plans/260105-1250-cliproxy-version-management/
This commit is contained in:
@@ -27,3 +27,20 @@ export const VERSION_PIN_FILE = '.version-pin';
|
|||||||
/** GitHub API URL for latest release (CLIProxyAPIPlus fork with Kiro + Copilot support) */
|
/** GitHub API URL for latest release (CLIProxyAPIPlus fork with Kiro + Copilot support) */
|
||||||
export const GITHUB_API_LATEST_RELEASE =
|
export const GITHUB_API_LATEST_RELEASE =
|
||||||
'https://api.github.com/repos/router-for-me/CLIProxyAPIPlus/releases/latest';
|
'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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,12 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { getCliproxyDir, getBinDir } from '../config-generator';
|
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
|
* Get path to version cache file
|
||||||
@@ -140,3 +145,52 @@ export function clearPinnedVersion(): void {
|
|||||||
export function isVersionPinned(): boolean {
|
export function isVersionPinned(): boolean {
|
||||||
return getPinnedVersion() !== null;
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,8 +4,20 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchJson } from './downloader';
|
import { fetchJson } from './downloader';
|
||||||
import { readVersionCache, writeVersionCache, readInstalledVersion } from './version-cache';
|
import {
|
||||||
import { UpdateCheckResult, GITHUB_API_LATEST_RELEASE } from './types';
|
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)
|
* Compare semver versions (true if latest > current)
|
||||||
@@ -85,3 +97,45 @@ export async function checkForUpdates(
|
|||||||
checkedAt: now,
|
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<VersionListResult> {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ import {
|
|||||||
} from '../../cliproxy/config-generator';
|
} from '../../cliproxy/config-generator';
|
||||||
import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker';
|
import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker';
|
||||||
import { ensureCliproxyService } from '../../cliproxy/service-manager';
|
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();
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* In remote mode: shows remote server info instead of local controls.
|
* In remote mode: shows remote server info instead of local controls.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Power,
|
Power,
|
||||||
@@ -15,11 +16,32 @@ import {
|
|||||||
Square,
|
Square,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
Globe,
|
Globe,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Settings,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
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 { Input } from '@/components/ui/input';
|
||||||
|
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 { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api, type CliproxyServerConfig } from '@/lib/api-client';
|
import { api, type CliproxyServerConfig } from '@/lib/api-client';
|
||||||
import {
|
import {
|
||||||
@@ -27,9 +49,23 @@ import {
|
|||||||
useStartProxy,
|
useStartProxy,
|
||||||
useStopProxy,
|
useStopProxy,
|
||||||
useCliproxyUpdateCheck,
|
useCliproxyUpdateCheck,
|
||||||
|
useCliproxyVersions,
|
||||||
|
useInstallVersion,
|
||||||
|
useRestartProxy,
|
||||||
} from '@/hooks/use-cliproxy';
|
} from '@/hooks/use-cliproxy';
|
||||||
import { cn } from '@/lib/utils';
|
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 {
|
function formatUptime(startedAt?: string): string {
|
||||||
if (!startedAt) return '';
|
if (!startedAt) return '';
|
||||||
const start = new Date(startedAt).getTime();
|
const start = new Date(startedAt).getTime();
|
||||||
@@ -59,8 +95,20 @@ function formatTimeAgo(timestamp?: number): string {
|
|||||||
export function ProxyStatusWidget() {
|
export function ProxyStatusWidget() {
|
||||||
const { data: status, isLoading } = useProxyStatus();
|
const { data: status, isLoading } = useProxyStatus();
|
||||||
const { data: updateCheck } = useCliproxyUpdateCheck();
|
const { data: updateCheck } = useCliproxyUpdateCheck();
|
||||||
|
const { data: versionsData, isLoading: versionsLoading } = useCliproxyVersions();
|
||||||
const startProxy = useStartProxy();
|
const startProxy = useStartProxy();
|
||||||
const stopProxy = useStopProxy();
|
const stopProxy = useStopProxy();
|
||||||
|
const restartProxy = useRestartProxy();
|
||||||
|
const installVersion = useInstallVersion();
|
||||||
|
|
||||||
|
// Version picker state
|
||||||
|
const [showVersionSettings, setShowVersionSettings] = useState(false);
|
||||||
|
const [selectedVersion, setSelectedVersion] = useState<string>('');
|
||||||
|
const [manualVersion, setManualVersion] = useState('');
|
||||||
|
|
||||||
|
// Confirmation dialog state for unstable versions
|
||||||
|
const [showUnstableConfirm, setShowUnstableConfirm] = useState(false);
|
||||||
|
const [pendingInstallVersion, setPendingInstallVersion] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch cliproxy_server config for remote mode detection
|
// Fetch cliproxy_server config for remote mode detection
|
||||||
const { data: cliproxyConfig } = useQuery<CliproxyServerConfig>({
|
const { data: cliproxyConfig } = useQuery<CliproxyServerConfig>({
|
||||||
@@ -74,10 +122,45 @@ export function ProxyStatusWidget() {
|
|||||||
const isRemoteMode = remoteConfig?.enabled && remoteConfig?.host;
|
const isRemoteMode = remoteConfig?.enabled && remoteConfig?.host;
|
||||||
|
|
||||||
const isRunning = status?.running ?? false;
|
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 hasUpdate = updateCheck?.hasUpdate ?? false;
|
||||||
const isUnstable = updateCheck?.isStable === false;
|
const isUnstable = updateCheck?.isStable === false;
|
||||||
|
|
||||||
|
// 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
|
// Build remote display info
|
||||||
const remoteDisplayHost = isRemoteMode
|
const remoteDisplayHost = isRemoteMode
|
||||||
? (() => {
|
? (() => {
|
||||||
@@ -190,8 +273,26 @@ export function ProxyStatusWidget() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Control buttons when running */}
|
{/* Control buttons when running: Restart | Update/Downgrade | Stop | Settings */}
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
{/* Restart button - pure restart, no version change */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs gap-1"
|
||||||
|
onClick={() => restartProxy.mutate()}
|
||||||
|
disabled={isActioning}
|
||||||
|
title="Restart CLIProxy service (no version change)"
|
||||||
|
>
|
||||||
|
{restartProxy.isPending ? (
|
||||||
|
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RotateCw className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
Restart
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Update/Downgrade button - version change */}
|
||||||
<Button
|
<Button
|
||||||
variant={hasUpdate || isUnstable ? 'default' : 'outline'}
|
variant={hasUpdate || isUnstable ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -203,26 +304,26 @@ export function ProxyStatusWidget() {
|
|||||||
'bg-sidebar-accent hover:bg-sidebar-accent/90 text-sidebar-accent-foreground'
|
'bg-sidebar-accent hover:bg-sidebar-accent/90 text-sidebar-accent-foreground'
|
||||||
)}
|
)}
|
||||||
onClick={handleRestart}
|
onClick={handleRestart}
|
||||||
disabled={isActioning}
|
disabled={isActioning || (!hasUpdate && !isUnstable)}
|
||||||
title={
|
title={
|
||||||
isUnstable
|
isUnstable
|
||||||
? `Current v${updateCheck?.currentVersion} is unstable. Restart to downgrade to stable v${updateCheck?.maxStableVersion}`
|
? `Downgrade to stable v${updateCheck?.maxStableVersion}`
|
||||||
: hasUpdate
|
: hasUpdate
|
||||||
? `Restart to update: v${updateCheck?.currentVersion} -> v${updateCheck?.latestVersion}`
|
? `Update to v${updateCheck?.latestVersion}`
|
||||||
: 'Restart CLIProxy service'
|
: 'Already on latest version'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isActioning ? (
|
{isActioning && !restartProxy.isPending ? (
|
||||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||||
) : isUnstable ? (
|
) : isUnstable ? (
|
||||||
<AlertTriangle className="w-3 h-3" />
|
<AlertTriangle className="w-3 h-3" />
|
||||||
) : hasUpdate ? (
|
) : hasUpdate ? (
|
||||||
<ArrowUp className="w-3 h-3" />
|
<ArrowUp className="w-3 h-3" />
|
||||||
) : (
|
) : null}
|
||||||
<RotateCw className="w-3 h-3" />
|
{isUnstable ? 'Downgrade' : hasUpdate ? 'Update' : 'Latest'}
|
||||||
)}
|
|
||||||
{isUnstable ? 'Downgrade' : hasUpdate ? 'Update' : 'Restart'}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Stop button */}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -238,7 +339,106 @@ export function ProxyStatusWidget() {
|
|||||||
)}
|
)}
|
||||||
Stop
|
Stop
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Settings gear - toggle version picker */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn('h-7 w-7 p-0', showVersionSettings && 'bg-muted')}
|
||||||
|
onClick={() => setShowVersionSettings(!showVersionSettings)}
|
||||||
|
title="Version settings"
|
||||||
|
>
|
||||||
|
<Settings className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Version Settings (collapsible) */}
|
||||||
|
<Collapsible open={showVersionSettings} onOpenChange={setShowVersionSettings}>
|
||||||
|
<CollapsibleContent className="mt-2 pt-2 border-t border-muted">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Current version */}
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">Current:</span>
|
||||||
|
<span
|
||||||
|
className={cn('font-mono', isUnstable && 'text-amber-600 dark:text-amber-400')}
|
||||||
|
>
|
||||||
|
v{updateCheck?.currentVersion}
|
||||||
|
{isUnstable && ' (unstable)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Version picker row */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Dropdown */}
|
||||||
|
<Select
|
||||||
|
value={selectedVersion}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setSelectedVersion(v);
|
||||||
|
setManualVersion('');
|
||||||
|
}}
|
||||||
|
disabled={versionsLoading}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs flex-1">
|
||||||
|
<SelectValue placeholder="Select version..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{versionsData?.versions.slice(0, 20).map((v) => (
|
||||||
|
<SelectItem key={v} value={v} className="text-xs">
|
||||||
|
v{v}
|
||||||
|
{v === versionsData.latestStable && ' (stable)'}
|
||||||
|
{v === versionsData.latest &&
|
||||||
|
v !== versionsData.latestStable &&
|
||||||
|
' (latest)'}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Manual input */}
|
||||||
|
<Input
|
||||||
|
placeholder="Manual..."
|
||||||
|
value={manualVersion}
|
||||||
|
onChange={(e) => {
|
||||||
|
setManualVersion(e.target.value);
|
||||||
|
setSelectedVersion('');
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs w-24"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Install button */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs gap-1"
|
||||||
|
onClick={() => handleInstallVersion(manualVersion || selectedVersion)}
|
||||||
|
disabled={installVersion.isPending || (!selectedVersion && !manualVersion)}
|
||||||
|
>
|
||||||
|
{installVersion.isPending ? (
|
||||||
|
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
Install
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stability warning */}
|
||||||
|
{(selectedVersion || manualVersion) &&
|
||||||
|
versionsData &&
|
||||||
|
isNewerVersionClient(
|
||||||
|
manualVersion || selectedVersion,
|
||||||
|
versionsData.maxStableVersion
|
||||||
|
) && (
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-amber-600 dark:text-amber-400">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
<span>
|
||||||
|
Versions above {versionsData.maxStableVersion} have known stability issues
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 flex items-center justify-between">
|
<div className="mt-2 flex items-center justify-between">
|
||||||
@@ -284,6 +484,38 @@ export function ProxyStatusWidget() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Unstable Version Confirmation Dialog */}
|
||||||
|
<AlertDialog open={showUnstableConfirm} onOpenChange={setShowUnstableConfirm}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||||
|
Install Unstable Version?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="space-y-2">
|
||||||
|
<p>
|
||||||
|
You are about to install <strong>v{pendingInstallVersion}</strong>, which is above
|
||||||
|
the maximum stable version{' '}
|
||||||
|
<strong>v{versionsData?.maxStableVersion || '6.6.80'}</strong>.
|
||||||
|
</p>
|
||||||
|
<p className="text-amber-600 dark:text-amber-400">
|
||||||
|
This version has known stability issues and may cause unexpected behavior.
|
||||||
|
</p>
|
||||||
|
<p>Are you sure you want to proceed?</p>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={handleCancelUnstableInstall}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleConfirmUnstableInstall}
|
||||||
|
className="bg-amber-500 hover:bg-amber-600 text-white"
|
||||||
|
>
|
||||||
|
Install Anyway
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,3 +304,59 @@ export function useCliproxyUpdateCheck() {
|
|||||||
refetchOnWindowFocus: false, // Don't refresh on window focus (save API calls)
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -261,6 +261,34 @@ export interface CliproxyUpdateCheckResult {
|
|||||||
checkedAt: number; // Unix timestamp of last check
|
checkedAt: number; // Unix timestamp of last check
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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
|
// API
|
||||||
export const api = {
|
export const api = {
|
||||||
profiles: {
|
profiles: {
|
||||||
@@ -301,6 +329,15 @@ export const api = {
|
|||||||
proxyStop: () => request<ProxyStopResult>('/cliproxy/proxy-stop', { method: 'POST' }),
|
proxyStop: () => request<ProxyStopResult>('/cliproxy/proxy-stop', { method: 'POST' }),
|
||||||
updateCheck: () => request<CliproxyUpdateCheckResult>('/cliproxy/update-check'),
|
updateCheck: () => request<CliproxyUpdateCheckResult>('/cliproxy/update-check'),
|
||||||
|
|
||||||
|
// Version management
|
||||||
|
versions: () => request<CliproxyVersionsResponse>('/cliproxy/versions'),
|
||||||
|
install: (version: string, force?: boolean) =>
|
||||||
|
request<CliproxyInstallResult>('/cliproxy/install', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ version, force }),
|
||||||
|
}),
|
||||||
|
restart: () => request<CliproxyRestartResult>('/cliproxy/restart', { method: 'POST' }),
|
||||||
|
|
||||||
// Stats and models for Overview tab
|
// Stats and models for Overview tab
|
||||||
stats: () => request<{ usage: Record<string, unknown> }>('/cliproxy/usage'),
|
stats: () => request<{ usage: Record<string, unknown> }>('/cliproxy/usage'),
|
||||||
models: () => request<CliproxyModelsResponse>('/cliproxy/models'),
|
models: () => request<CliproxyModelsResponse>('/cliproxy/models'),
|
||||||
|
|||||||
Reference in New Issue
Block a user