fix: confirm risky cliproxy installs in dashboard

This commit is contained in:
Tam Nhu Tran
2026-03-07 11:13:49 +07:00
parent ad01196964
commit aed7beb075
8 changed files with 437 additions and 131 deletions
@@ -949,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.`,
});
@@ -958,6 +960,8 @@ 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.`,
});
@@ -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);
@@ -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,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>
+6 -1
View File
@@ -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;
+26
View File
@@ -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;
}
+17
View File
@@ -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>.',