diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index 4f92fc38..87290273 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -949,6 +949,8 @@ router.post('/install', async (req: Request, res: Response): Promise => { 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.`, }); @@ -958,6 +960,8 @@ router.post('/install', async (req: Request, res: Response): Promise => { 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.`, }); diff --git a/tests/unit/cliproxy/binary-manager-install.test.ts b/tests/unit/cliproxy/binary-manager-install.test.ts index b30bf963..b2a9bddb 100644 --- a/tests/unit/cliproxy/binary-manager-install.test.ts +++ b/tests/unit/cliproxy/binary-manager-install.test.ts @@ -1,96 +1,94 @@ -import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test'; - -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: '6.6.80', - 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: () => {}, -})); - -let binaryManager: typeof import('../../../src/cliproxy/binary-manager'); - -beforeAll(async () => { - binaryManager = await import('../../../src/cliproxy/binary-manager'); -}); - -afterAll(() => { - mock.restore(); -}); +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); diff --git a/tests/unit/ui/cliproxy-version-risk.test.ts b/tests/unit/ui/cliproxy-version-risk.test.ts new file mode 100644 index 00000000..b44c398f --- /dev/null +++ b/tests/unit/ui/cliproxy-version-risk.test.ts @@ -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); + }); +}); diff --git a/tests/unit/web-server/cliproxy-stats-routes-install.test.ts b/tests/unit/web-server/cliproxy-stats-routes-install.test.ts new file mode 100644 index 00000000..a64465e7 --- /dev/null +++ b/tests/unit/web-server/cliproxy-stats-routes-install.test.ts @@ -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((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((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); + }); +}); diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index 6e3c2adc..4d69d54b 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -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(''); - // Confirmation dialog state for unstable versions + // Confirmation dialog state for risky versions const [showUnstableConfirm, setShowUnstableConfirm] = useState(false); const [pendingInstallVersion, setPendingInstallVersion] = useState(null); + const [pendingInstallRisk, setPendingInstallRisk] = useState(null); // Fetch cliproxy_server config for remote mode detection const { data: cliproxyConfig } = useQuery({ @@ -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() { {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 ( @@ -461,7 +490,7 @@ export function ProxyStatusWidget() { {t('proxyStatusWidget.stable')} )} - {vIsUnstable && ( + {(vIsFaulty || vIsExperimental) && ( )} @@ -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) && (
@@ -502,6 +531,23 @@ export function ProxyStatusWidget() {
)} + {selectedVersion && + versionsData?.faultyRange && + isCliproxyVersionInRange( + selectedVersion, + versionsData.faultyRange.min, + versionsData.faultyRange.max + ) && ( +
+ + + {t('proxyStatusWidget.versionsKnownIssues', { + version: selectedVersion, + })} + +
+ )} + {/* Sync time */} {updateCheck?.checkedAt && (
@@ -542,21 +588,38 @@ export function ProxyStatusWidget() { - {t('proxyStatusWidget.installUnstableTitle')} + {pendingInstallRisk === 'faulty' + ? t('proxyStatusWidget.installFaultyTitle') + : t('proxyStatusWidget.installUnstableTitle')} -

- }} - /> -

+ {pendingInstallRisk === 'faulty' ? ( +

+ }} + /> +

+ ) : ( +

+ }} + /> +

+ )}

- {t('proxyStatusWidget.installUnstableWarning')} + {pendingInstallRisk === 'faulty' + ? t('proxyStatusWidget.installFaultyWarning') + : t('proxyStatusWidget.installUnstableWarning')}

{t('proxyStatusWidget.installUnstableConfirm')}

diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 607873fa..111c895c 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -598,6 +598,10 @@ export interface CliproxyVersionsResponse { latest: string; currentVersion: string; maxStableVersion: string; + faultyRange?: { + min: string; + max: string; + }; fromCache: boolean; checkedAt: number; } @@ -608,7 +612,8 @@ export interface CliproxyInstallResult { version?: string; restarted?: boolean; port?: number; - isUnstable?: boolean; + isFaulty?: boolean; + isExperimental?: boolean; requiresConfirmation?: boolean; message?: string; error?: string; diff --git a/ui/src/lib/cliproxy-version-risk.ts b/ui/src/lib/cliproxy-version-risk.ts new file mode 100644 index 00000000..3e55ea2e --- /dev/null +++ b/ui/src/lib/cliproxy-version-risk.ts @@ -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; +} diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 3d72b140..c990d792 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -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 v{{version}}, which falls inside the known faulty range {{range}}.', + 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 v{{version}}, which is above the maximum stable version v{{maxStable}}.', @@ -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: + '即将安装 v{{version}},该版本位于已知故障范围 {{range}} 内。', + installFaultyWarning: '该版本存在已知缺陷,可能安装失败或让代理处于异常状态。', installUnstableTitle: '安装非稳定版本?', installUnstableDesc: '即将安装 v{{version}},该版本高于当前最大稳定版 v{{maxStable}}。', @@ -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 v{{version}}, phiên bản này nằm trong dải lỗi đã biết {{range}}.', + 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 v{{version}}, phiên bản này cao hơn phiên bản ổn định tối đa v{{maxStable}}.',