mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 12:20:44 +00:00
fix(cliproxy): degrade gracefully on GitHub release 403s
- fall back to stale CLIProxy version caches instead of surfacing 500s - remove duplicate dashboard update-check fetches and disable retry/focus refetch - add backend and UI regressions for the degraded-path behavior
This commit is contained in:
@@ -33,6 +33,23 @@ export function getVersionPinPath(backend: CLIProxyBackend = DEFAULT_BACKEND): s
|
||||
* Read version cache if still valid (backend-specific)
|
||||
*/
|
||||
export function readVersionCache(backend: CLIProxyBackend = DEFAULT_BACKEND): VersionCache | null {
|
||||
return readVersionCacheInternal(backend, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read version cache even if expired.
|
||||
* Used as a resilience fallback when GitHub release lookups fail.
|
||||
*/
|
||||
export function readStaleVersionCache(
|
||||
backend: CLIProxyBackend = DEFAULT_BACKEND
|
||||
): VersionCache | null {
|
||||
return readVersionCacheInternal(backend, true);
|
||||
}
|
||||
|
||||
function readVersionCacheInternal(
|
||||
backend: CLIProxyBackend,
|
||||
allowExpired: boolean
|
||||
): VersionCache | null {
|
||||
const cachePath = getVersionCachePath(backend);
|
||||
if (!fs.existsSync(cachePath)) {
|
||||
return null;
|
||||
@@ -42,8 +59,8 @@ export function readVersionCache(backend: CLIProxyBackend = DEFAULT_BACKEND): Ve
|
||||
const content = fs.readFileSync(cachePath, 'utf8');
|
||||
const cache: VersionCache = JSON.parse(content);
|
||||
|
||||
// Check if cache is still valid
|
||||
if (Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) {
|
||||
// Check if cache is still valid, unless caller explicitly allows stale fallback.
|
||||
if (allowExpired || Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
@@ -192,6 +209,23 @@ export function getVersionListCachePath(backend: CLIProxyBackend = DEFAULT_BACKE
|
||||
*/
|
||||
export function readVersionListCache(
|
||||
backend: CLIProxyBackend = DEFAULT_BACKEND
|
||||
): VersionListCache | null {
|
||||
return readVersionListCacheInternal(backend, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read version list cache even if expired.
|
||||
* Used as a resilience fallback when GitHub release lookups fail.
|
||||
*/
|
||||
export function readStaleVersionListCache(
|
||||
backend: CLIProxyBackend = DEFAULT_BACKEND
|
||||
): VersionListCache | null {
|
||||
return readVersionListCacheInternal(backend, true);
|
||||
}
|
||||
|
||||
function readVersionListCacheInternal(
|
||||
backend: CLIProxyBackend,
|
||||
allowExpired: boolean
|
||||
): VersionListCache | null {
|
||||
const cachePath = getVersionListCachePath(backend);
|
||||
if (!fs.existsSync(cachePath)) {
|
||||
@@ -202,8 +236,8 @@ export function readVersionListCache(
|
||||
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) {
|
||||
// Check if cache is still valid, unless caller explicitly allows stale fallback.
|
||||
if (allowExpired || Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
import { fetchJson } from './downloader';
|
||||
import {
|
||||
readVersionCache,
|
||||
readStaleVersionCache,
|
||||
writeVersionCache,
|
||||
readInstalledVersion,
|
||||
readVersionListCache,
|
||||
readStaleVersionListCache,
|
||||
writeVersionListCache,
|
||||
} from './version-cache';
|
||||
import { UpdateCheckResult, VersionListResult, getGitHubApiUrls } from './types';
|
||||
@@ -19,6 +21,18 @@ import {
|
||||
} from '../platform-detector';
|
||||
import type { CLIProxyBackend } from '../types';
|
||||
|
||||
interface FetchLatestVersionDeps {
|
||||
fetchJsonFn?: typeof fetchJson;
|
||||
}
|
||||
|
||||
interface CheckForUpdatesDeps {
|
||||
fetchLatestVersionFn?: (verbose: boolean, backend: CLIProxyBackend) => Promise<string>;
|
||||
}
|
||||
|
||||
interface FetchAllVersionsDeps {
|
||||
fetchJsonFn?: typeof fetchJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare semver versions (true if latest > current)
|
||||
* Handles CLIProxyAPIPlus versioning: strips -0 suffix before comparison
|
||||
@@ -61,10 +75,11 @@ export function isVersionFaulty(version: string): boolean {
|
||||
*/
|
||||
export async function fetchLatestVersion(
|
||||
verbose = false,
|
||||
backend: CLIProxyBackend = DEFAULT_BACKEND
|
||||
backend: CLIProxyBackend = DEFAULT_BACKEND,
|
||||
deps: FetchLatestVersionDeps = {}
|
||||
): Promise<string> {
|
||||
const urls = getGitHubApiUrls(backend);
|
||||
const response = await fetchJson(urls.latestRelease, verbose);
|
||||
const response = await (deps.fetchJsonFn ?? fetchJson)(urls.latestRelease, verbose);
|
||||
|
||||
// Extract version from tag_name (format: "v6.5.27" or "6.5.27")
|
||||
const tagName = response.tag_name as string;
|
||||
@@ -84,7 +99,8 @@ export async function checkForUpdates(
|
||||
binPath: string,
|
||||
configVersion: string,
|
||||
verbose = false,
|
||||
backend: CLIProxyBackend = DEFAULT_BACKEND
|
||||
backend: CLIProxyBackend = DEFAULT_BACKEND,
|
||||
deps: CheckForUpdatesDeps = {}
|
||||
): Promise<UpdateCheckResult> {
|
||||
const currentVersion = readInstalledVersion(binPath, configVersion);
|
||||
|
||||
@@ -103,18 +119,38 @@ export async function checkForUpdates(
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch from GitHub API (backend-specific repo)
|
||||
const latestVersion = await fetchLatestVersion(verbose, backend);
|
||||
const now = Date.now();
|
||||
writeVersionCache(latestVersion, backend);
|
||||
try {
|
||||
// Fetch from GitHub API (backend-specific repo)
|
||||
const latestVersion = await (deps.fetchLatestVersionFn ?? fetchLatestVersion)(verbose, backend);
|
||||
const now = Date.now();
|
||||
writeVersionCache(latestVersion, backend);
|
||||
|
||||
return {
|
||||
hasUpdate: isNewerVersion(latestVersion, currentVersion),
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
fromCache: false,
|
||||
checkedAt: now,
|
||||
};
|
||||
return {
|
||||
hasUpdate: isNewerVersion(latestVersion, currentVersion),
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
fromCache: false,
|
||||
checkedAt: now,
|
||||
};
|
||||
} catch (error) {
|
||||
const staleCache = readStaleVersionCache(backend);
|
||||
if (staleCache) {
|
||||
if (verbose) {
|
||||
console.error(
|
||||
`[cliproxy] GitHub latest release lookup failed, using stale cache: ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
return {
|
||||
hasUpdate: isNewerVersion(staleCache.latestVersion, currentVersion),
|
||||
currentVersion,
|
||||
latestVersion: staleCache.latestVersion,
|
||||
fromCache: true,
|
||||
checkedAt: staleCache.checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,7 +161,8 @@ export async function checkForUpdates(
|
||||
*/
|
||||
export async function fetchAllVersions(
|
||||
verbose = false,
|
||||
backend: CLIProxyBackend = DEFAULT_BACKEND
|
||||
backend: CLIProxyBackend = DEFAULT_BACKEND,
|
||||
deps: FetchAllVersionsDeps = {}
|
||||
): Promise<VersionListResult> {
|
||||
// Try cache first (backend-specific)
|
||||
const cache = readVersionListCache(backend);
|
||||
@@ -136,31 +173,46 @@ export async function fetchAllVersions(
|
||||
return { ...cache, fromCache: true };
|
||||
}
|
||||
|
||||
// Fetch from GitHub API (backend-specific repo)
|
||||
const urls = getGitHubApiUrls(backend);
|
||||
const response = await fetchJson(urls.allReleases, verbose);
|
||||
try {
|
||||
// Fetch from GitHub API (backend-specific repo)
|
||||
const urls = getGitHubApiUrls(backend);
|
||||
const response = await (deps.fetchJsonFn ?? fetchJson)(urls.allReleases, 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
|
||||
// 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] || '';
|
||||
const latest = versions[0] || '';
|
||||
|
||||
// Find latest stable (not newer than max stable AND not in faulty range)
|
||||
const latestStable =
|
||||
versions.find((v) => !isNewerVersion(v, CLIPROXY_MAX_STABLE_VERSION) && !isVersionFaulty(v)) ||
|
||||
CLIPROXY_MAX_STABLE_VERSION;
|
||||
// Find latest stable (not newer than max stable AND not in faulty range)
|
||||
const latestStable =
|
||||
versions.find(
|
||||
(v) => !isNewerVersion(v, CLIPROXY_MAX_STABLE_VERSION) && !isVersionFaulty(v)
|
||||
) || CLIPROXY_MAX_STABLE_VERSION;
|
||||
|
||||
const result: VersionListResult = {
|
||||
versions,
|
||||
latestStable,
|
||||
latest,
|
||||
fromCache: false,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
const result: VersionListResult = {
|
||||
versions,
|
||||
latestStable,
|
||||
latest,
|
||||
fromCache: false,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
|
||||
writeVersionListCache(result, backend);
|
||||
return result;
|
||||
writeVersionListCache(result, backend);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const staleCache = readStaleVersionListCache(backend);
|
||||
if (staleCache) {
|
||||
if (verbose) {
|
||||
console.error(
|
||||
`[cliproxy] GitHub release list lookup failed, using stale cache: ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
return { ...staleCache, fromCache: true };
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,89 @@ function getConfiguredBackend() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildUpdateCheckFallback(
|
||||
backend: ReturnType<typeof getConfiguredBackend>,
|
||||
getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion
|
||||
) {
|
||||
const currentVersion = getInstalledVersionFn(backend);
|
||||
const isStable = !isNewerVersion(currentVersion, CLIPROXY_MAX_STABLE_VERSION);
|
||||
const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
|
||||
|
||||
return {
|
||||
hasUpdate: false,
|
||||
currentVersion,
|
||||
latestVersion: currentVersion,
|
||||
fromCache: true,
|
||||
checkedAt: Date.now(),
|
||||
backend,
|
||||
backendLabel,
|
||||
isStable,
|
||||
maxStableVersion: CLIPROXY_MAX_STABLE_VERSION,
|
||||
stabilityMessage: isStable
|
||||
? undefined
|
||||
: `v${currentVersion} has known stability issues. Max stable: v${CLIPROXY_MAX_STABLE_VERSION}`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVersionsFallback(
|
||||
backend: ReturnType<typeof getConfiguredBackend>,
|
||||
getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion
|
||||
) {
|
||||
const currentVersion = getInstalledVersionFn(backend);
|
||||
|
||||
return {
|
||||
versions: currentVersion ? [currentVersion] : [],
|
||||
latestStable: currentVersion || CLIPROXY_MAX_STABLE_VERSION,
|
||||
latest: currentVersion || CLIPROXY_MAX_STABLE_VERSION,
|
||||
fromCache: true,
|
||||
checkedAt: Date.now(),
|
||||
currentVersion,
|
||||
maxStableVersion: CLIPROXY_MAX_STABLE_VERSION,
|
||||
faultyRange: CLIPROXY_FAULTY_RANGE,
|
||||
};
|
||||
}
|
||||
|
||||
interface ResolveUpdateCheckDeps {
|
||||
checkCliproxyUpdateFn?: typeof checkCliproxyUpdate;
|
||||
getInstalledVersionFn?: typeof getInstalledCliproxyVersion;
|
||||
}
|
||||
|
||||
interface ResolveVersionsDeps {
|
||||
fetchAllVersionsFn?: typeof fetchAllVersions;
|
||||
getInstalledVersionFn?: typeof getInstalledCliproxyVersion;
|
||||
}
|
||||
|
||||
export async function resolveCliproxyUpdateCheckPayload(
|
||||
backend: ReturnType<typeof getConfiguredBackend>,
|
||||
deps: ResolveUpdateCheckDeps = {}
|
||||
) {
|
||||
const checkCliproxyUpdateFn = deps.checkCliproxyUpdateFn ?? checkCliproxyUpdate;
|
||||
const getInstalledVersionFn = deps.getInstalledVersionFn ?? getInstalledCliproxyVersion;
|
||||
|
||||
return checkCliproxyUpdateFn(backend).catch(() =>
|
||||
buildUpdateCheckFallback(backend, getInstalledVersionFn)
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveCliproxyVersionsPayload(
|
||||
backend: ReturnType<typeof getConfiguredBackend>,
|
||||
deps: ResolveVersionsDeps = {}
|
||||
) {
|
||||
const fetchAllVersionsFn = deps.fetchAllVersionsFn ?? fetchAllVersions;
|
||||
const getInstalledVersionFn = deps.getInstalledVersionFn ?? getInstalledCliproxyVersion;
|
||||
const result = await fetchAllVersionsFn(false, backend).catch(() => null);
|
||||
if (!result) {
|
||||
return buildVersionsFallback(backend, getInstalledVersionFn);
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
currentVersion: getInstalledVersionFn(backend),
|
||||
maxStableVersion: CLIPROXY_MAX_STABLE_VERSION,
|
||||
faultyRange: CLIPROXY_FAULTY_RANGE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract status code and model from error log file (lightweight parsing).
|
||||
* Reads first 4KB for model, last 2KB for status code. Async to avoid blocking event loop.
|
||||
@@ -327,7 +410,8 @@ router.post('/proxy-stop', async (_req: Request, res: Response): Promise<void> =
|
||||
router.get('/update-check', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const backend = getConfiguredBackend();
|
||||
const result = await checkCliproxyUpdate(backend);
|
||||
const result = await resolveCliproxyUpdateCheckPayload(backend);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error(`[cliproxy-stats] ${(error as Error).message}`);
|
||||
@@ -936,15 +1020,7 @@ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): P
|
||||
router.get('/versions', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const backend = getConfiguredBackend();
|
||||
const result = await fetchAllVersions(false, backend);
|
||||
const currentVersion = getInstalledCliproxyVersion(backend);
|
||||
|
||||
res.json({
|
||||
...result,
|
||||
currentVersion,
|
||||
maxStableVersion: CLIPROXY_MAX_STABLE_VERSION,
|
||||
faultyRange: CLIPROXY_FAULTY_RANGE,
|
||||
});
|
||||
res.json(await resolveCliproxyVersionsPayload(backend));
|
||||
} catch (error) {
|
||||
console.error(`[cliproxy-stats] ${(error as Error).message}`);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('version-checker stale cache fallback', () => {
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempHome = '';
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-version-checker-'));
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a stale latest-version cache when GitHub lookup fails', async () => {
|
||||
const {
|
||||
getVersionCachePath,
|
||||
writeInstalledVersion,
|
||||
} = await import('../../../src/cliproxy/binary/version-cache');
|
||||
const { VERSION_CACHE_DURATION_MS } = await import('../../../src/cliproxy/binary/types');
|
||||
const { checkForUpdates } = await import('../../../src/cliproxy/binary/version-checker');
|
||||
const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus');
|
||||
|
||||
fs.mkdirSync(plusBinDir, { recursive: true });
|
||||
writeInstalledVersion(plusBinDir, '6.6.80');
|
||||
fs.writeFileSync(
|
||||
getVersionCachePath('plus'),
|
||||
JSON.stringify({
|
||||
latestVersion: '6.9.23-0',
|
||||
checkedAt: Date.now() - VERSION_CACHE_DURATION_MS - 1_000,
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = await checkForUpdates(plusBinDir, '6.6.80', false, 'plus', {
|
||||
fetchLatestVersionFn: async () => {
|
||||
throw new Error('GitHub API error: HTTP 403');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.latestVersion).toBe('6.9.23-0');
|
||||
expect(result.currentVersion).toBe('6.6.80');
|
||||
expect(result.hasUpdate).toBe(true);
|
||||
expect(result.fromCache).toBe(true);
|
||||
});
|
||||
|
||||
it('uses a stale release-list cache when GitHub list lookup fails', async () => {
|
||||
const { getVersionListCachePath } = await import('../../../src/cliproxy/binary/version-cache');
|
||||
const { VERSION_CACHE_DURATION_MS } = await import('../../../src/cliproxy/binary/types');
|
||||
const { fetchAllVersions } = await import('../../../src/cliproxy/binary/version-checker');
|
||||
const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus');
|
||||
|
||||
fs.mkdirSync(plusBinDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
getVersionListCachePath('plus'),
|
||||
JSON.stringify({
|
||||
versions: ['6.9.23-0', '6.9.22-0', '6.9.19-0'],
|
||||
latestStable: '6.9.23-0',
|
||||
latest: '6.9.23-0',
|
||||
checkedAt: Date.now() - VERSION_CACHE_DURATION_MS - 1_000,
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = await fetchAllVersions(false, 'plus', {
|
||||
fetchJsonFn: async () => {
|
||||
throw new Error('GitHub API error: HTTP 403');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.versions).toEqual(['6.9.23-0', '6.9.22-0', '6.9.19-0']);
|
||||
expect(result.latestStable).toBe('6.9.23-0');
|
||||
expect(result.latest).toBe('6.9.23-0');
|
||||
expect(result.fromCache).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
describe('cliproxy-stats-routes version fallback', () => {
|
||||
it('returns a degraded update-check payload instead of propagating a 500', async () => {
|
||||
const { resolveCliproxyUpdateCheckPayload } = await import(
|
||||
'../../../src/web-server/routes/cliproxy-stats-routes'
|
||||
);
|
||||
|
||||
const body = await resolveCliproxyUpdateCheckPayload('plus', {
|
||||
checkCliproxyUpdateFn: async () => {
|
||||
throw new Error('GitHub API error: HTTP 403');
|
||||
},
|
||||
getInstalledVersionFn: () => '6.6.80',
|
||||
});
|
||||
|
||||
expect(body).toMatchObject({
|
||||
hasUpdate: false,
|
||||
currentVersion: '6.6.80',
|
||||
latestVersion: '6.6.80',
|
||||
backend: 'plus',
|
||||
backendLabel: 'CLIProxy Plus',
|
||||
fromCache: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a degraded versions payload instead of propagating a 500', async () => {
|
||||
const { resolveCliproxyVersionsPayload } = await import(
|
||||
'../../../src/web-server/routes/cliproxy-stats-routes'
|
||||
);
|
||||
|
||||
const body = await resolveCliproxyVersionsPayload('plus', {
|
||||
fetchAllVersionsFn: async () => {
|
||||
throw new Error('GitHub API error: HTTP 403');
|
||||
},
|
||||
getInstalledVersionFn: () => '6.6.80',
|
||||
});
|
||||
|
||||
expect(body).toMatchObject({
|
||||
versions: ['6.6.80'],
|
||||
latestStable: '6.6.80',
|
||||
latest: '6.6.80',
|
||||
currentVersion: '6.6.80',
|
||||
fromCache: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,22 +3,15 @@
|
||||
* Fixed header with OAuth login buttons, status indicator, and refresh
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { RefreshCw, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||
import { useCliproxyAuth, useCliproxyUpdateCheck } from '@/hooks/use-cliproxy';
|
||||
import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
|
||||
|
||||
interface VersionInfo {
|
||||
currentVersion: string;
|
||||
isStable: boolean;
|
||||
stabilityMessage?: string;
|
||||
backendLabel?: string;
|
||||
}
|
||||
|
||||
interface LoginButtonProps {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
@@ -116,25 +109,9 @@ export function CliproxyHeader({
|
||||
isRunning = true,
|
||||
}: CliproxyHeaderProps) {
|
||||
const { data: authData } = useCliproxyAuth();
|
||||
const { data: updateCheck } = useCliproxyUpdateCheck();
|
||||
const { provider: authProvider, isAuthenticating, startAuth } = useCliproxyAuthFlow();
|
||||
const lastUpdatedText = useRelativeTime(lastUpdated);
|
||||
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/cliproxy/update-check')
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (data) {
|
||||
setVersionInfo({
|
||||
currentVersion: data.currentVersion,
|
||||
isStable: data.isStable,
|
||||
stabilityMessage: data.stabilityMessage,
|
||||
backendLabel: data.backendLabel,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {}); // Silently fail
|
||||
}, []);
|
||||
|
||||
const providers = CLIPROXY_PROVIDERS.map((id) => ({
|
||||
id,
|
||||
@@ -155,7 +132,7 @@ export function CliproxyHeader({
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{versionInfo?.backendLabel ?? 'CLIProxy'}
|
||||
{updateCheck?.backendLabel ?? 'CLIProxy'}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">CCS-level account management</p>
|
||||
</div>
|
||||
@@ -191,18 +168,18 @@ export function CliproxyHeader({
|
||||
{isRunning ? 'Running' : 'Offline'}
|
||||
</Badge>
|
||||
|
||||
{versionInfo && (
|
||||
{updateCheck && (
|
||||
<Badge
|
||||
variant={versionInfo.isStable ? 'secondary' : 'destructive'}
|
||||
variant={updateCheck.isStable ? 'secondary' : 'destructive'}
|
||||
className={cn(
|
||||
'gap-1.5',
|
||||
!versionInfo.isStable &&
|
||||
!updateCheck.isStable &&
|
||||
'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30'
|
||||
)}
|
||||
title={versionInfo.stabilityMessage}
|
||||
title={updateCheck.stabilityMessage}
|
||||
>
|
||||
{!versionInfo.isStable && <AlertTriangle className="w-3 h-3" />}v
|
||||
{versionInfo.currentVersion}
|
||||
{!updateCheck.isStable && <AlertTriangle className="w-3 h-3" />}v
|
||||
{updateCheck.currentVersion}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
|
||||
@@ -447,7 +447,8 @@ export function useCliproxyUpdateCheck() {
|
||||
queryFn: () => api.cliproxy.updateCheck(),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes (reduced from 1 hour for faster backend switch response)
|
||||
refetchInterval: 5 * 60 * 1000, // Refresh every 5 minutes
|
||||
refetchOnWindowFocus: true, // Refetch on window focus to catch backend changes
|
||||
refetchOnWindowFocus: false, // Avoid refetch bursts for non-critical release metadata
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -492,7 +493,8 @@ export function useCliproxyVersions() {
|
||||
queryKey: ['cliproxy-versions'],
|
||||
queryFn: () => api.cliproxy.versions(),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes (reduced for faster backend switch response)
|
||||
refetchOnWindowFocus: true, // Refetch on focus to catch backend changes
|
||||
refetchOnWindowFocus: false, // Avoid repeated release lookups while browsing the dashboard
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '../../../setup/test-utils';
|
||||
|
||||
const hookMocks = vi.hoisted(() => ({
|
||||
startAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-cliproxy', () => ({
|
||||
useCliproxyAuth: () => ({
|
||||
data: {
|
||||
authStatus: [],
|
||||
},
|
||||
}),
|
||||
useCliproxyUpdateCheck: () => ({
|
||||
data: {
|
||||
backendLabel: 'CLIProxy Plus',
|
||||
currentVersion: '6.9.23-0',
|
||||
isStable: true,
|
||||
stabilityMessage: undefined,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-cliproxy-auth-flow', () => ({
|
||||
useCliproxyAuthFlow: () => ({
|
||||
provider: null,
|
||||
isAuthenticating: false,
|
||||
startAuth: hookMocks.startAuth,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { CliproxyHeader } from '@/components/cliproxy/cliproxy-header';
|
||||
|
||||
describe('CliproxyHeader', () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('renders version data from the shared update query without issuing a direct fetch', () => {
|
||||
render(
|
||||
<CliproxyHeader onRefresh={vi.fn()} isRefreshing={false} lastUpdated={new Date()} isRunning />
|
||||
);
|
||||
|
||||
expect(screen.getByText('CLIProxy Plus')).toBeInTheDocument();
|
||||
expect(screen.getByText('v6.9.23-0')).toBeInTheDocument();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user