mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 08:19:59 +00:00
Merge pull request #707 from kaitranntt/kai/fix/706-proxy-version-install-restart
fix: restart CLIProxy after dashboard version install
This commit is contained in:
@@ -9,7 +9,7 @@ import { info, warn } from '../utils/ui';
|
||||
import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config-generator';
|
||||
import { BinaryInfo, BinaryManagerConfig } from './types';
|
||||
import { BACKEND_CONFIG, DEFAULT_BACKEND, CLIPROXY_MAX_STABLE_VERSION } from './platform-detector';
|
||||
import { isProxyRunning, stopProxy } from './services/proxy-lifecycle-service';
|
||||
import { stopProxy } from './services/proxy-lifecycle-service';
|
||||
import { waitForPortFree } from '../utils/port-utils';
|
||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import {
|
||||
@@ -167,19 +167,18 @@ export async function installCliproxyVersion(
|
||||
const effectiveBackend = backend ?? getConfiguredBackend();
|
||||
const manager = new BinaryManager({ version, verbose, forceVersion: true }, effectiveBackend);
|
||||
|
||||
// Check if proxy is running and stop it first
|
||||
if (isProxyRunning()) {
|
||||
if (verbose) console.log(info('Stopping running CLIProxy before update...'));
|
||||
const result = await stopProxy();
|
||||
if (result.stopped) {
|
||||
// Wait for port to be fully released
|
||||
const portFree = await waitForPortFree(CLIPROXY_DEFAULT_PORT, 5000);
|
||||
if (!portFree && verbose) {
|
||||
console.log(warn('Port did not free up in time, proceeding anyway...'));
|
||||
}
|
||||
} else if (verbose && result.error) {
|
||||
console.log(warn(`Could not stop proxy: ${result.error}`));
|
||||
// Always attempt a best-effort stop first so we also catch untracked proxies
|
||||
// that are running without a session lock.
|
||||
if (verbose) console.log(info('Stopping running CLIProxy before update...'));
|
||||
const result = await stopProxy();
|
||||
if (result.stopped) {
|
||||
// Wait for port to be fully released
|
||||
const portFree = await waitForPortFree(CLIPROXY_DEFAULT_PORT, 5000);
|
||||
if (!portFree && verbose) {
|
||||
console.log(warn('Port did not free up in time, proceeding anyway...'));
|
||||
}
|
||||
} else if (verbose && result.error && result.error !== 'No active CLIProxy session found') {
|
||||
console.log(warn(`Could not stop proxy: ${result.error}`));
|
||||
}
|
||||
|
||||
if (manager.isBinaryInstalled()) {
|
||||
|
||||
@@ -34,11 +34,7 @@ import {
|
||||
} from '../../cliproxy/config-generator';
|
||||
import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker';
|
||||
import { ensureCliproxyService } from '../../cliproxy/service-manager';
|
||||
import {
|
||||
checkCliproxyUpdate,
|
||||
getInstalledCliproxyVersion,
|
||||
installCliproxyVersion,
|
||||
} from '../../cliproxy/binary-manager';
|
||||
import { checkCliproxyUpdate, getInstalledCliproxyVersion } from '../../cliproxy/binary-manager';
|
||||
import {
|
||||
fetchAllVersions,
|
||||
isNewerVersion,
|
||||
@@ -56,6 +52,7 @@ import {
|
||||
canonicalizeModelIdForProvider,
|
||||
getDeniedModelIdReasonForProvider,
|
||||
} from '../../cliproxy/model-id-normalizer';
|
||||
import { installDashboardCliproxyVersion } from '../services/cliproxy-dashboard-install-service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -928,7 +925,7 @@ router.get('/versions', async (_req: Request, res: Response): Promise<void> => {
|
||||
/**
|
||||
* POST /api/cliproxy/install - Install specific CLIProxyAPI version
|
||||
* Body: { version: string, force?: boolean }
|
||||
* Returns: { success, requiresConfirmation?, message? }
|
||||
* Returns: { success, restarted?, port?, requiresConfirmation?, message? }
|
||||
*/
|
||||
router.post('/install', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -952,6 +949,8 @@ router.post('/install', async (req: Request, res: Response): Promise<void> => {
|
||||
if (isFaulty && !force) {
|
||||
res.json({
|
||||
success: false,
|
||||
isFaulty,
|
||||
isExperimental,
|
||||
requiresConfirmation: true,
|
||||
message: `Version ${version} has known bugs (v${CLIPROXY_FAULTY_RANGE.min.replace(/-\d+$/, '')}-${CLIPROXY_FAULTY_RANGE.max.replace(/-\d+$/, '')}). Set force=true to proceed.`,
|
||||
});
|
||||
@@ -961,28 +960,22 @@ router.post('/install', async (req: Request, res: Response): Promise<void> => {
|
||||
if (isExperimental && !force) {
|
||||
res.json({
|
||||
success: false,
|
||||
isFaulty,
|
||||
isExperimental,
|
||||
requiresConfirmation: true,
|
||||
message: `Version ${version} is experimental (above stable ${CLIPROXY_MAX_STABLE_VERSION.replace(/-\d+$/, '')}). 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
|
||||
const backend = getConfiguredBackend();
|
||||
await installCliproxyVersion(version, true, backend);
|
||||
const installResult = await installDashboardCliproxyVersion(version, backend);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
version,
|
||||
isFaulty,
|
||||
isExperimental,
|
||||
message: `Successfully installed CLIProxy Plus v${version}`,
|
||||
...installResult,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[cliproxy-stats] ${(error as Error).message}`);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { installCliproxyVersion } from '../../cliproxy/binary-manager';
|
||||
import { ensureCliproxyService, type ServiceStartResult } from '../../cliproxy/service-manager';
|
||||
import { getProxyStatus as getProxyProcessStatus } from '../../cliproxy/session-tracker';
|
||||
import { isCliproxyRunning } from '../../cliproxy/stats-fetcher';
|
||||
import type { CLIProxyBackend } from '../../cliproxy/types';
|
||||
|
||||
interface ProxyStatusLike {
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
interface InstallDashboardCliproxyVersionDeps {
|
||||
getProxyStatus: () => ProxyStatusLike;
|
||||
isCliproxyRunning: () => Promise<boolean>;
|
||||
installCliproxyVersion: (
|
||||
version: string,
|
||||
verbose?: boolean,
|
||||
backend?: CLIProxyBackend
|
||||
) => Promise<void>;
|
||||
ensureCliproxyService: () => Promise<ServiceStartResult>;
|
||||
}
|
||||
|
||||
const defaultDeps: InstallDashboardCliproxyVersionDeps = {
|
||||
getProxyStatus: getProxyProcessStatus,
|
||||
isCliproxyRunning,
|
||||
installCliproxyVersion,
|
||||
ensureCliproxyService: () => ensureCliproxyService(),
|
||||
};
|
||||
|
||||
export interface DashboardCliproxyInstallResult {
|
||||
success: boolean;
|
||||
restarted: boolean;
|
||||
port?: number;
|
||||
message: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function wasProxyRunning(deps: InstallDashboardCliproxyVersionDeps): Promise<boolean> {
|
||||
const status = deps.getProxyStatus();
|
||||
if (status.running) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return deps.isCliproxyRunning();
|
||||
}
|
||||
|
||||
export async function installDashboardCliproxyVersion(
|
||||
version: string,
|
||||
backend: CLIProxyBackend,
|
||||
deps: InstallDashboardCliproxyVersionDeps = defaultDeps
|
||||
): Promise<DashboardCliproxyInstallResult> {
|
||||
const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
|
||||
const shouldRestoreService = await wasProxyRunning(deps);
|
||||
|
||||
// The installer owns the stop-and-replace lifecycle, including best-effort
|
||||
// shutdown for tracked and untracked proxies before swapping the binary.
|
||||
await deps.installCliproxyVersion(version, true, backend);
|
||||
|
||||
if (!shouldRestoreService) {
|
||||
return {
|
||||
success: true,
|
||||
restarted: false,
|
||||
message: `Successfully installed ${backendLabel} v${version}`,
|
||||
};
|
||||
}
|
||||
|
||||
const startResult = await deps.ensureCliproxyService();
|
||||
if (!startResult.started && !startResult.alreadyRunning) {
|
||||
return {
|
||||
success: false,
|
||||
restarted: false,
|
||||
error: startResult.error || `Installed ${backendLabel} v${version}, but restart failed`,
|
||||
message: `Installed ${backendLabel} v${version}, but failed to restart it`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
restarted: true,
|
||||
port: startResult.port,
|
||||
message: `Successfully installed ${backendLabel} v${version} and restarted it on port ${startResult.port}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
describe('installCliproxyVersion', () => {
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('attempts to stop the proxy even when there is no tracked running session', async () => {
|
||||
const calls = {
|
||||
stopProxy: 0,
|
||||
waitForPortFree: 0,
|
||||
deleteBinary: 0,
|
||||
ensureBinary: 0,
|
||||
};
|
||||
|
||||
mock.module('../../../src/utils/ui', () => ({
|
||||
info: (message: string) => message,
|
||||
warn: (message: string) => message,
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/config-generator', () => ({
|
||||
getBinDir: () => '/tmp/ccs-bin',
|
||||
CLIPROXY_DEFAULT_PORT: 8317,
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/platform-detector', () => ({
|
||||
DEFAULT_BACKEND: 'plus',
|
||||
CLIPROXY_MAX_STABLE_VERSION: '9.9.999-0',
|
||||
BACKEND_CONFIG: {
|
||||
plus: {
|
||||
fallbackVersion: '6.6.80',
|
||||
repo: 'router-for-me/CLIProxyAPIPlus',
|
||||
},
|
||||
original: {
|
||||
fallbackVersion: '0.0.0',
|
||||
repo: 'router-for-me/CLIProxyAPI',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/services/proxy-lifecycle-service', () => ({
|
||||
stopProxy: async () => {
|
||||
calls.stopProxy += 1;
|
||||
return { stopped: false, error: 'No active CLIProxy session found' };
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../../src/utils/port-utils', () => ({
|
||||
waitForPortFree: async () => {
|
||||
calls.waitForPortFree += 1;
|
||||
return true;
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../../src/config/unified-config-loader', () => ({
|
||||
loadOrCreateUnifiedConfig: () => ({
|
||||
cliproxy: { backend: 'plus' },
|
||||
}),
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/binary', () => ({
|
||||
checkForUpdates: async () => ({
|
||||
hasUpdate: false,
|
||||
currentVersion: '6.6.80',
|
||||
latestVersion: '6.6.80',
|
||||
fromCache: false,
|
||||
checkedAt: Date.now(),
|
||||
}),
|
||||
deleteBinary: () => {
|
||||
calls.deleteBinary += 1;
|
||||
},
|
||||
getBinaryPath: () => '/tmp/ccs-bin/plus/cliproxy',
|
||||
isBinaryInstalled: () => false,
|
||||
getBinaryInfo: async () => null,
|
||||
getPinnedVersion: () => null,
|
||||
savePinnedVersion: () => {},
|
||||
clearPinnedVersion: () => {},
|
||||
isVersionPinned: () => false,
|
||||
getVersionPinPath: () => '/tmp/ccs-bin/plus/.version-pin',
|
||||
readInstalledVersion: () => '6.6.80',
|
||||
ensureBinary: async () => {
|
||||
calls.ensureBinary += 1;
|
||||
return '/tmp/ccs-bin/plus/cliproxy';
|
||||
},
|
||||
migrateVersionPin: () => {},
|
||||
}));
|
||||
|
||||
const binaryManager = await import(
|
||||
`../../../src/cliproxy/binary-manager?binary-manager-install=${Date.now()}`
|
||||
);
|
||||
|
||||
await binaryManager.installCliproxyVersion('6.7.1', false, 'plus');
|
||||
|
||||
expect(calls.stopProxy).toBe(1);
|
||||
expect(calls.waitForPortFree).toBe(0);
|
||||
expect(calls.deleteBinary).toBe(0);
|
||||
expect(calls.ensureBinary).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
compareCliproxyVersions,
|
||||
isCliproxyVersionExperimental,
|
||||
isCliproxyVersionInRange,
|
||||
} from '../../../ui/src/lib/cliproxy-version-risk';
|
||||
|
||||
describe('cliproxy-version-risk helpers', () => {
|
||||
it('compares versions while ignoring release suffixes', () => {
|
||||
expect(compareCliproxyVersions('6.6.88', '6.6.81-0')).toBe(1);
|
||||
expect(compareCliproxyVersions('6.6.81-0', '6.6.81')).toBe(0);
|
||||
expect(compareCliproxyVersions('6.6.80', '6.6.81')).toBe(-1);
|
||||
});
|
||||
|
||||
it('detects experimental versions against max stable', () => {
|
||||
expect(isCliproxyVersionExperimental('10.0.0', '9.9.999-0')).toBe(true);
|
||||
expect(isCliproxyVersionExperimental('6.6.88', '9.9.999-0')).toBe(false);
|
||||
});
|
||||
|
||||
it('detects versions inside the faulty range', () => {
|
||||
expect(isCliproxyVersionInRange('6.6.81', '6.6.81-0', '6.6.88-0')).toBe(true);
|
||||
expect(isCliproxyVersionInRange('6.6.88', '6.6.81-0', '6.6.88-0')).toBe(true);
|
||||
expect(isCliproxyVersionInRange('6.6.89', '6.6.81-0', '6.6.88-0')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import type { CLIProxyBackend } from '../../../src/cliproxy/types';
|
||||
import {
|
||||
installDashboardCliproxyVersion,
|
||||
type DashboardCliproxyInstallResult,
|
||||
} from '../../../src/web-server/services/cliproxy-dashboard-install-service';
|
||||
|
||||
function createDeps(
|
||||
overrides: {
|
||||
sessionRunning?: boolean;
|
||||
remoteRunning?: boolean;
|
||||
startResult?: { started: boolean; alreadyRunning: boolean; port: number; error?: string };
|
||||
} = {}
|
||||
) {
|
||||
const calls = {
|
||||
isCliproxyRunning: 0,
|
||||
installCliproxyVersion: 0,
|
||||
ensureCliproxyService: 0,
|
||||
};
|
||||
|
||||
const deps = {
|
||||
getProxyStatus: () => ({ running: overrides.sessionRunning ?? false }),
|
||||
isCliproxyRunning: async () => {
|
||||
calls.isCliproxyRunning += 1;
|
||||
return overrides.remoteRunning ?? false;
|
||||
},
|
||||
installCliproxyVersion: async (
|
||||
_version: string,
|
||||
_verbose?: boolean,
|
||||
_backend?: CLIProxyBackend
|
||||
) => {
|
||||
calls.installCliproxyVersion += 1;
|
||||
},
|
||||
ensureCliproxyService: async () => {
|
||||
calls.ensureCliproxyService += 1;
|
||||
return (
|
||||
overrides.startResult ?? {
|
||||
started: true,
|
||||
alreadyRunning: false,
|
||||
port: 8317,
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return { deps, calls };
|
||||
}
|
||||
|
||||
describe('installDashboardCliproxyVersion', () => {
|
||||
it('restarts the proxy after install when it was already running', async () => {
|
||||
const { deps, calls } = createDeps({ sessionRunning: true });
|
||||
|
||||
const result = await installDashboardCliproxyVersion('6.7.1', 'plus', deps);
|
||||
|
||||
expect(result).toEqual<DashboardCliproxyInstallResult>({
|
||||
success: true,
|
||||
restarted: true,
|
||||
port: 8317,
|
||||
message: 'Successfully installed CLIProxy Plus v6.7.1 and restarted it on port 8317',
|
||||
});
|
||||
expect(calls.isCliproxyRunning).toBe(0);
|
||||
expect(calls.installCliproxyVersion).toBe(1);
|
||||
expect(calls.ensureCliproxyService).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the proxy stopped after install when it was not running beforehand', async () => {
|
||||
const { deps, calls } = createDeps({ sessionRunning: false, remoteRunning: false });
|
||||
|
||||
const result = await installDashboardCliproxyVersion('6.7.1', 'plus', deps);
|
||||
|
||||
expect(result).toEqual<DashboardCliproxyInstallResult>({
|
||||
success: true,
|
||||
restarted: false,
|
||||
message: 'Successfully installed CLIProxy Plus v6.7.1',
|
||||
});
|
||||
expect(calls.isCliproxyRunning).toBe(1);
|
||||
expect(calls.installCliproxyVersion).toBe(1);
|
||||
expect(calls.ensureCliproxyService).toBe(0);
|
||||
});
|
||||
|
||||
it('reports a restart failure after a successful install when the proxy had been running', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
sessionRunning: false,
|
||||
remoteRunning: true,
|
||||
startResult: {
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
port: 8317,
|
||||
error: 'Port 8317 is blocked by another process',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await installDashboardCliproxyVersion('6.7.1', 'original', deps);
|
||||
|
||||
expect(result).toEqual<DashboardCliproxyInstallResult>({
|
||||
success: false,
|
||||
restarted: false,
|
||||
error: 'Port 8317 is blocked by another process',
|
||||
message: 'Installed CLIProxy v6.7.1, but failed to restart it',
|
||||
});
|
||||
expect(calls.isCliproxyRunning).toBe(1);
|
||||
expect(calls.installCliproxyVersion).toBe(1);
|
||||
expect(calls.ensureCliproxyService).toBe(1);
|
||||
});
|
||||
|
||||
it('uses a fallback restart error when the start result omits one', async () => {
|
||||
const { deps } = createDeps({
|
||||
remoteRunning: true,
|
||||
startResult: {
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
port: 8317,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await installDashboardCliproxyVersion('6.7.1', 'plus', deps);
|
||||
|
||||
expect(result).toEqual<DashboardCliproxyInstallResult>({
|
||||
success: false,
|
||||
restarted: false,
|
||||
error: 'Installed CLIProxy Plus v6.7.1, but restart failed',
|
||||
message: 'Installed CLIProxy Plus v6.7.1, but failed to restart it',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
import express from 'express';
|
||||
import type { Server } from 'http';
|
||||
|
||||
const installSpy = {
|
||||
calls: 0,
|
||||
};
|
||||
|
||||
mock.module('../../../src/config/unified-config-loader', () => ({
|
||||
loadOrCreateUnifiedConfig: () => ({
|
||||
cliproxy: { backend: 'plus' },
|
||||
}),
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/binary-manager', () => ({
|
||||
checkCliproxyUpdate: async () => ({
|
||||
hasUpdate: false,
|
||||
currentVersion: '6.6.80',
|
||||
latestVersion: '6.6.89',
|
||||
fromCache: false,
|
||||
checkedAt: Date.now(),
|
||||
backend: 'plus',
|
||||
backendLabel: 'CLIProxy Plus',
|
||||
isStable: true,
|
||||
maxStableVersion: '9.9.999-0',
|
||||
}),
|
||||
getInstalledCliproxyVersion: () => '6.6.80',
|
||||
installCliproxyVersion: async () => {},
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/binary/version-checker', () => ({
|
||||
fetchAllVersions: async () => ({
|
||||
versions: ['6.6.89', '6.6.88', '6.6.81', '6.6.80'],
|
||||
latestStable: '6.6.89',
|
||||
latest: '6.6.89',
|
||||
fromCache: false,
|
||||
checkedAt: Date.now(),
|
||||
}),
|
||||
isNewerVersion: (version: string, maxStable: string) => {
|
||||
const normalize = (value: string) => value.replace(/-\d+$/, '').split('.').map(Number);
|
||||
const versionParts = normalize(version);
|
||||
const maxStableParts = normalize(maxStable);
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const versionPart = versionParts[index] || 0;
|
||||
const maxStablePart = maxStableParts[index] || 0;
|
||||
|
||||
if (versionPart > maxStablePart) return true;
|
||||
if (versionPart < maxStablePart) return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
isVersionFaulty: (version: string) =>
|
||||
['6.6.81', '6.6.82', '6.6.83', '6.6.84', '6.6.85', '6.6.86', '6.6.87', '6.6.88'].includes(
|
||||
version
|
||||
),
|
||||
}));
|
||||
|
||||
mock.module('../../../src/web-server/services/cliproxy-dashboard-install-service', () => ({
|
||||
installDashboardCliproxyVersion: async () => {
|
||||
installSpy.calls += 1;
|
||||
return {
|
||||
success: true,
|
||||
restarted: true,
|
||||
port: 8317,
|
||||
message: 'installed',
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
let cliproxyStatsRoutes: typeof import('../../../src/web-server/routes/cliproxy-stats-routes').default;
|
||||
let server: Server;
|
||||
let baseUrl = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
cliproxyStatsRoutes = (await import('../../../src/web-server/routes/cliproxy-stats-routes'))
|
||||
.default;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/cliproxy', cliproxyStatsRoutes);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = app.listen(0, '127.0.0.1');
|
||||
const onError = (error: Error) => reject(error);
|
||||
server.once('error', onError);
|
||||
server.once('listening', () => {
|
||||
server.off('error', onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve test server port');
|
||||
}
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
installSpy.calls = 0;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe('cliproxy-stats-routes install contract', () => {
|
||||
it('returns faultyRange in the versions response', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/cliproxy/versions`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
faultyRange: { min: string; max: string };
|
||||
currentVersion: string;
|
||||
};
|
||||
expect(body.currentVersion).toBe('6.6.80');
|
||||
expect(body.faultyRange).toEqual({ min: '6.6.81-0', max: '6.6.88-0' });
|
||||
});
|
||||
|
||||
it('returns faulty confirmation metadata without calling the installer', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/cliproxy/install`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ version: '6.6.81' }),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
success: boolean;
|
||||
requiresConfirmation: boolean;
|
||||
isFaulty: boolean;
|
||||
isExperimental: boolean;
|
||||
message: string;
|
||||
};
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.requiresConfirmation).toBe(true);
|
||||
expect(body.isFaulty).toBe(true);
|
||||
expect(body.isExperimental).toBe(false);
|
||||
expect(body.message).toContain('known bugs');
|
||||
expect(installSpy.calls).toBe(0);
|
||||
});
|
||||
|
||||
it('returns experimental confirmation metadata without calling the installer', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/cliproxy/install`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ version: '10.0.0' }),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
success: boolean;
|
||||
requiresConfirmation: boolean;
|
||||
isFaulty: boolean;
|
||||
isExperimental: boolean;
|
||||
message: string;
|
||||
};
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.requiresConfirmation).toBe(true);
|
||||
expect(body.isFaulty).toBe(false);
|
||||
expect(body.isExperimental).toBe(true);
|
||||
expect(body.message).toContain('experimental');
|
||||
expect(installSpy.calls).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -63,17 +63,12 @@ import {
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import { useSyncStatus, useExecuteSync } from '@/hooks/use-cliproxy-sync';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
isCliproxyVersionExperimental,
|
||||
isCliproxyVersionInRange,
|
||||
} from '@/lib/cliproxy-version-risk';
|
||||
|
||||
/** 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;
|
||||
}
|
||||
type PendingInstallRisk = 'faulty' | 'experimental';
|
||||
|
||||
function formatUptime(startedAt?: string): string {
|
||||
if (!startedAt) return '';
|
||||
@@ -168,9 +163,10 @@ export function ProxyStatusWidget() {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [selectedVersion, setSelectedVersion] = useState<string>('');
|
||||
|
||||
// Confirmation dialog state for unstable versions
|
||||
// Confirmation dialog state for risky versions
|
||||
const [showUnstableConfirm, setShowUnstableConfirm] = useState(false);
|
||||
const [pendingInstallVersion, setPendingInstallVersion] = useState<string | null>(null);
|
||||
const [pendingInstallRisk, setPendingInstallRisk] = useState<PendingInstallRisk | null>(null);
|
||||
|
||||
// Fetch cliproxy_server config for remote mode detection
|
||||
const { data: cliproxyConfig } = useQuery<CliproxyServerConfig>({
|
||||
@@ -208,36 +204,62 @@ export function ProxyStatusWidget() {
|
||||
const targetVersion = isUnstable
|
||||
? updateCheck?.maxStableVersion || versionsData?.latestStable
|
||||
: updateCheck?.latestVersion;
|
||||
const maxStableVersion =
|
||||
versionsData?.maxStableVersion || updateCheck?.maxStableVersion || '6.6.80';
|
||||
|
||||
// Handle version install (shows confirmation for unstable)
|
||||
const handleInstallVersion = (version: string) => {
|
||||
const faultyRange = versionsData?.faultyRange;
|
||||
const faultyRangeLabel =
|
||||
faultyRange &&
|
||||
`${faultyRange.min.replace(/-\d+$/, '')}-${faultyRange.max.replace(/-\d+$/, '')}`;
|
||||
|
||||
const queueInstallConfirmation = (version: string, risk: PendingInstallRisk) => {
|
||||
setPendingInstallVersion(version);
|
||||
setPendingInstallRisk(risk);
|
||||
setShowUnstableConfirm(true);
|
||||
};
|
||||
|
||||
// Handle version install (shows confirmation for risky versions)
|
||||
const handleInstallVersion = async (version: string) => {
|
||||
if (!version) return;
|
||||
const maxStable = versionsData?.maxStableVersion || '6.6.80';
|
||||
const isVersionUnstable = isNewerVersionClient(version, maxStable);
|
||||
const isVersionExperimental = isCliproxyVersionExperimental(version, maxStableVersion);
|
||||
const isVersionFaulty =
|
||||
faultyRange !== undefined &&
|
||||
isCliproxyVersionInRange(version, faultyRange.min, faultyRange.max);
|
||||
|
||||
if (isVersionUnstable) {
|
||||
// Show confirmation dialog for unstable versions
|
||||
setPendingInstallVersion(version);
|
||||
setShowUnstableConfirm(true);
|
||||
if (isVersionFaulty) {
|
||||
queueInstallConfirmation(version, 'faulty');
|
||||
return;
|
||||
}
|
||||
|
||||
// Install directly if stable
|
||||
installVersion.mutate({ version });
|
||||
if (isVersionExperimental) {
|
||||
queueInstallConfirmation(version, 'experimental');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await installVersion.mutateAsync({ version });
|
||||
if (result.requiresConfirmation) {
|
||||
queueInstallConfirmation(version, result.isFaulty ? 'faulty' : 'experimental');
|
||||
}
|
||||
} catch {
|
||||
// Hook-level onError already reports install failures.
|
||||
}
|
||||
};
|
||||
|
||||
// Confirm unstable version install
|
||||
// Confirm risky version install
|
||||
const handleConfirmUnstableInstall = () => {
|
||||
if (pendingInstallVersion) {
|
||||
installVersion.mutate({ version: pendingInstallVersion, force: true });
|
||||
}
|
||||
setShowUnstableConfirm(false);
|
||||
setPendingInstallVersion(null);
|
||||
setPendingInstallRisk(null);
|
||||
};
|
||||
|
||||
const handleCancelUnstableInstall = () => {
|
||||
setShowUnstableConfirm(false);
|
||||
setPendingInstallVersion(null);
|
||||
setPendingInstallRisk(null);
|
||||
};
|
||||
|
||||
// Build remote display info
|
||||
@@ -372,7 +394,7 @@ export function ProxyStatusWidget() {
|
||||
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50'
|
||||
: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50'
|
||||
)}
|
||||
onClick={() => handleInstallVersion(targetVersion)}
|
||||
onClick={() => void handleInstallVersion(targetVersion)}
|
||||
title={
|
||||
isUnstable
|
||||
? t('proxyStatusWidget.clickToDowngrade')
|
||||
@@ -449,9 +471,16 @@ export function ProxyStatusWidget() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{versionsData?.versions.slice(0, 20).map((v) => {
|
||||
const vIsUnstable =
|
||||
const vIsExperimental =
|
||||
versionsData?.maxStableVersion &&
|
||||
isNewerVersionClient(v, versionsData.maxStableVersion);
|
||||
isCliproxyVersionExperimental(v, versionsData.maxStableVersion);
|
||||
const vIsFaulty =
|
||||
versionsData?.faultyRange &&
|
||||
isCliproxyVersionInRange(
|
||||
v,
|
||||
versionsData.faultyRange.min,
|
||||
versionsData.faultyRange.max
|
||||
);
|
||||
return (
|
||||
<SelectItem key={v} value={v} className="text-xs">
|
||||
<span className="flex items-center gap-2">
|
||||
@@ -461,7 +490,7 @@ export function ProxyStatusWidget() {
|
||||
{t('proxyStatusWidget.stable')}
|
||||
</span>
|
||||
)}
|
||||
{vIsUnstable && (
|
||||
{(vIsFaulty || vIsExperimental) && (
|
||||
<span className="text-amber-600 dark:text-amber-400">⚠</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -476,7 +505,7 @@ export function ProxyStatusWidget() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs gap-1.5 px-3"
|
||||
onClick={() => handleInstallVersion(selectedVersion)}
|
||||
onClick={() => void handleInstallVersion(selectedVersion)}
|
||||
disabled={installVersion.isPending || !selectedVersion}
|
||||
>
|
||||
{installVersion.isPending ? (
|
||||
@@ -491,7 +520,7 @@ export function ProxyStatusWidget() {
|
||||
{/* Stability warning for selected version */}
|
||||
{selectedVersion &&
|
||||
versionsData?.maxStableVersion &&
|
||||
isNewerVersionClient(selectedVersion, versionsData.maxStableVersion) && (
|
||||
isCliproxyVersionExperimental(selectedVersion, versionsData.maxStableVersion) && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>
|
||||
@@ -502,6 +531,23 @@ export function ProxyStatusWidget() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedVersion &&
|
||||
versionsData?.faultyRange &&
|
||||
isCliproxyVersionInRange(
|
||||
selectedVersion,
|
||||
versionsData.faultyRange.min,
|
||||
versionsData.faultyRange.max
|
||||
) && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>
|
||||
{t('proxyStatusWidget.versionsKnownIssues', {
|
||||
version: selectedVersion,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sync time */}
|
||||
{updateCheck?.checkedAt && (
|
||||
<div className="mt-2 text-[10px] text-muted-foreground/60">
|
||||
@@ -542,21 +588,38 @@ export function ProxyStatusWidget() {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||
{t('proxyStatusWidget.installUnstableTitle')}
|
||||
{pendingInstallRisk === 'faulty'
|
||||
? t('proxyStatusWidget.installFaultyTitle')
|
||||
: t('proxyStatusWidget.installUnstableTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="space-y-2">
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="proxyStatusWidget.installUnstableDesc"
|
||||
values={{
|
||||
version: pendingInstallVersion ?? '',
|
||||
maxStable: versionsData?.maxStableVersion || '6.6.80',
|
||||
}}
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</p>
|
||||
{pendingInstallRisk === 'faulty' ? (
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="proxyStatusWidget.installFaultyDesc"
|
||||
values={{
|
||||
version: pendingInstallVersion ?? '',
|
||||
range: faultyRangeLabel || '',
|
||||
}}
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="proxyStatusWidget.installUnstableDesc"
|
||||
values={{
|
||||
version: pendingInstallVersion ?? '',
|
||||
maxStable: maxStableVersion,
|
||||
}}
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
<p className="text-amber-600 dark:text-amber-400">
|
||||
{t('proxyStatusWidget.installUnstableWarning')}
|
||||
{pendingInstallRisk === 'faulty'
|
||||
? t('proxyStatusWidget.installFaultyWarning')
|
||||
: t('proxyStatusWidget.installUnstableWarning')}
|
||||
</p>
|
||||
<p>{t('proxyStatusWidget.installUnstableConfirm')}</p>
|
||||
</AlertDialogDescription>
|
||||
|
||||
@@ -598,6 +598,10 @@ export interface CliproxyVersionsResponse {
|
||||
latest: string;
|
||||
currentVersion: string;
|
||||
maxStableVersion: string;
|
||||
faultyRange?: {
|
||||
min: string;
|
||||
max: string;
|
||||
};
|
||||
fromCache: boolean;
|
||||
checkedAt: number;
|
||||
}
|
||||
@@ -606,7 +610,10 @@ export interface CliproxyVersionsResponse {
|
||||
export interface CliproxyInstallResult {
|
||||
success: boolean;
|
||||
version?: string;
|
||||
isUnstable?: boolean;
|
||||
restarted?: boolean;
|
||||
port?: number;
|
||||
isFaulty?: boolean;
|
||||
isExperimental?: boolean;
|
||||
requiresConfirmation?: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
function normalizeVersionParts(version: string): number[] {
|
||||
return version.replace(/-\d+$/, '').split('.').map(Number);
|
||||
}
|
||||
|
||||
export function compareCliproxyVersions(a: string, b: string): number {
|
||||
const aParts = normalizeVersionParts(a);
|
||||
const bParts = normalizeVersionParts(b);
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const aPart = aParts[index] || 0;
|
||||
const bPart = bParts[index] || 0;
|
||||
|
||||
if (aPart > bPart) return 1;
|
||||
if (aPart < bPart) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function isCliproxyVersionExperimental(version: string, maxStableVersion: string): boolean {
|
||||
return compareCliproxyVersions(version, maxStableVersion) > 0;
|
||||
}
|
||||
|
||||
export function isCliproxyVersionInRange(version: string, min: string, max: string): boolean {
|
||||
return compareCliproxyVersions(version, min) >= 0 && compareCliproxyVersions(version, max) <= 0;
|
||||
}
|
||||
@@ -286,12 +286,18 @@ const resources = {
|
||||
stable: '(stable)',
|
||||
install: 'Install',
|
||||
versionsAboveUnstable: 'Versions above {{version}} have known issues',
|
||||
versionsKnownIssues: 'Version {{version}} has known issues',
|
||||
lastChecked: 'Last checked {{time}}',
|
||||
notRunning: 'Not running',
|
||||
start: 'Start',
|
||||
port: 'Port {{port}}',
|
||||
sessionCount: '{{count}} session',
|
||||
sessionCount_other: '{{count}} sessions',
|
||||
installFaultyTitle: 'Install Version With Known Issues?',
|
||||
installFaultyDesc:
|
||||
'You are about to install <strong>v{{version}}</strong>, which falls inside the known faulty range <strong>{{range}}</strong>.',
|
||||
installFaultyWarning:
|
||||
'This version has known bugs and may fail or leave the proxy in a bad state.',
|
||||
installUnstableTitle: 'Install Unstable Version?',
|
||||
installUnstableDesc:
|
||||
'You are about to install <strong>v{{version}}</strong>, which is above the maximum stable version <strong>v{{maxStable}}</strong>.',
|
||||
@@ -1427,12 +1433,17 @@ const resources = {
|
||||
stable: '(稳定版)',
|
||||
install: '安装',
|
||||
versionsAboveUnstable: '高于 {{version}} 的版本存在已知问题',
|
||||
versionsKnownIssues: '版本 {{version}} 存在已知问题',
|
||||
lastChecked: '上次检查 {{time}}',
|
||||
notRunning: '未运行',
|
||||
start: '启动',
|
||||
port: '端口 {{port}}',
|
||||
sessionCount: '{{count}} 个会话',
|
||||
sessionCount_other: '{{count}} 个会话',
|
||||
installFaultyTitle: '安装存在已知问题的版本?',
|
||||
installFaultyDesc:
|
||||
'即将安装 <strong>v{{version}}</strong>,该版本位于已知故障范围 <strong>{{range}}</strong> 内。',
|
||||
installFaultyWarning: '该版本存在已知缺陷,可能安装失败或让代理处于异常状态。',
|
||||
installUnstableTitle: '安装非稳定版本?',
|
||||
installUnstableDesc:
|
||||
'即将安装 <strong>v{{version}}</strong>,该版本高于当前最大稳定版 <strong>v{{maxStable}}</strong>。',
|
||||
@@ -2539,12 +2550,18 @@ const resources = {
|
||||
stable: '(ổn định)',
|
||||
install: 'Cài đặt',
|
||||
versionsAboveUnstable: 'Các phiên bản trên {{version}} có vấn đề đã biết',
|
||||
versionsKnownIssues: 'Phiên bản {{version}} có vấn đề đã biết',
|
||||
lastChecked: 'Đã kiểm tra lần cuối {{time}}',
|
||||
notRunning: 'Không chạy',
|
||||
start: 'Bắt đầu',
|
||||
port: 'Cổng {{port}}',
|
||||
sessionCount: '{{count}} phiên',
|
||||
sessionCount_other: '{{count}} phiên',
|
||||
installFaultyTitle: 'Cài đặt phiên bản có lỗi đã biết?',
|
||||
installFaultyDesc:
|
||||
'Bạn sắp cài đặt <strong>v{{version}}</strong>, phiên bản này nằm trong dải lỗi đã biết <strong>{{range}}</strong>.',
|
||||
installFaultyWarning:
|
||||
'Phiên bản này có lỗi đã biết và có thể cài đặt thất bại hoặc làm proxy ở trạng thái xấu.',
|
||||
installUnstableTitle: 'Cài đặt phiên bản không ổn định?',
|
||||
installUnstableDesc:
|
||||
'Bạn sắp cài đặt <strong>v{{version}}</strong>, phiên bản này cao hơn phiên bản ổn định tối đa <strong>v{{maxStable}}</strong>.',
|
||||
|
||||
Reference in New Issue
Block a user