mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 14:19:56 +00:00
Merge pull request #130 from kaitranntt/kai/feat/session-persistence
feat(cliproxy): session persistence and dashboard status widget
This commit is contained in:
@@ -318,13 +318,11 @@ export async function execClaudeWithCLIProxy(
|
||||
const existingProxy = getExistingProxy(cfg.port);
|
||||
let proxy: ChildProcess | null = null;
|
||||
let sessionId: string;
|
||||
let isReusingProxy = false;
|
||||
|
||||
if (existingProxy) {
|
||||
// Reuse existing proxy - another CCS session started it
|
||||
log(`Reusing existing CLIProxy on port ${cfg.port} (PID ${existingProxy.pid})`);
|
||||
sessionId = registerSession(cfg.port, existingProxy.pid);
|
||||
isReusingProxy = true;
|
||||
console.log(
|
||||
info(`Joined existing CLIProxy (${existingProxy.sessions.length + 1} sessions active)`)
|
||||
);
|
||||
@@ -363,26 +361,20 @@ export async function execClaudeWithCLIProxy(
|
||||
}
|
||||
|
||||
// 6b. Spawn CLIProxyAPI binary (only if not reusing existing proxy)
|
||||
// Use detached mode so proxy persists after terminal closes
|
||||
const proxyArgs = ['--config', configPath];
|
||||
|
||||
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
|
||||
|
||||
proxy = spawn(binaryPath, proxyArgs, {
|
||||
stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'],
|
||||
detached: false,
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
detached: true, // Persist after parent terminal closes
|
||||
});
|
||||
|
||||
// Forward proxy output in verbose mode
|
||||
if (verbose) {
|
||||
proxy.stdout?.on('data', (data: Buffer) => {
|
||||
process.stderr.write(`[cliproxy-out] ${data.toString()}`);
|
||||
});
|
||||
proxy.stderr?.on('data', (data: Buffer) => {
|
||||
process.stderr.write(`[cliproxy-err] ${data.toString()}`);
|
||||
});
|
||||
}
|
||||
// Unref so parent process can exit independently
|
||||
proxy.unref();
|
||||
|
||||
// Handle proxy errors
|
||||
// Handle proxy errors (only fires if spawn itself fails)
|
||||
proxy.on('error', (error) => {
|
||||
console.error(fail(`CLIProxy spawn error: ${error.message}`));
|
||||
});
|
||||
@@ -478,25 +470,14 @@ export async function execClaudeWithCLIProxy(
|
||||
});
|
||||
}
|
||||
|
||||
// 8. Cleanup: unregister session when Claude exits, kill proxy only if last session
|
||||
// 8. Cleanup: unregister session when Claude exits
|
||||
// Proxy persists by default - use 'ccs cliproxy stop' to kill manually
|
||||
claude.on('exit', (code, signal) => {
|
||||
log(`Claude exited: code=${code}, signal=${signal}`);
|
||||
|
||||
// Unregister this session - returns true if we were the last session
|
||||
const shouldKillProxy = unregisterSession(sessionId);
|
||||
log(`Session ${sessionId} unregistered, shouldKillProxy=${shouldKillProxy}`);
|
||||
|
||||
if (shouldKillProxy && proxy) {
|
||||
// We were the last session and we own the proxy - kill it
|
||||
log('Last session, killing proxy');
|
||||
proxy.kill('SIGTERM');
|
||||
} else if (shouldKillProxy && isReusingProxy) {
|
||||
// We were the last session but don't own the proxy process
|
||||
// The proxy will be cleaned up as zombie on next session start
|
||||
log('Last session but reusing proxy, proxy will be cleaned up later');
|
||||
} else {
|
||||
log(`Other sessions still active, keeping proxy running`);
|
||||
}
|
||||
// Unregister this session (proxy keeps running for persistence)
|
||||
unregisterSession(sessionId);
|
||||
log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`);
|
||||
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal as NodeJS.Signals);
|
||||
@@ -508,11 +489,8 @@ export async function execClaudeWithCLIProxy(
|
||||
claude.on('error', (error) => {
|
||||
console.error(fail(`Claude CLI error: ${error}`));
|
||||
|
||||
// Unregister and conditionally kill proxy
|
||||
const shouldKillProxy = unregisterSession(sessionId);
|
||||
if (shouldKillProxy && proxy) {
|
||||
proxy.kill('SIGTERM');
|
||||
}
|
||||
// Unregister session, proxy keeps running
|
||||
unregisterSession(sessionId);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -520,26 +498,13 @@ export async function execClaudeWithCLIProxy(
|
||||
const cleanup = () => {
|
||||
log('Parent signal received, cleaning up');
|
||||
|
||||
// Unregister and conditionally kill proxy
|
||||
const shouldKillProxy = unregisterSession(sessionId);
|
||||
if (shouldKillProxy && proxy) {
|
||||
proxy.kill('SIGTERM');
|
||||
}
|
||||
// Unregister session, proxy keeps running
|
||||
unregisterSession(sessionId);
|
||||
claude.kill('SIGTERM');
|
||||
};
|
||||
|
||||
process.once('SIGTERM', cleanup);
|
||||
process.once('SIGINT', cleanup);
|
||||
|
||||
// Handle proxy crash (only if we own the proxy)
|
||||
if (proxy) {
|
||||
proxy.on('exit', (code, signal) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
log(`Proxy exited unexpectedly: code=${code}, signal=${signal}`);
|
||||
// Don't kill Claude - it may have already exited
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -224,3 +224,78 @@ export function cleanupOrphanedSessions(port: number): void {
|
||||
deleteSessionLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the CLIProxy process and clean up session lock.
|
||||
* @returns Object with success status and details
|
||||
*/
|
||||
export function stopProxy(): {
|
||||
stopped: boolean;
|
||||
pid?: number;
|
||||
sessionCount?: number;
|
||||
error?: string;
|
||||
} {
|
||||
const lock = readSessionLock();
|
||||
|
||||
if (!lock) {
|
||||
return { stopped: false, error: 'No active CLIProxy session found' };
|
||||
}
|
||||
|
||||
// Check if proxy is running
|
||||
if (!isProcessRunning(lock.pid)) {
|
||||
deleteSessionLock();
|
||||
return { stopped: false, error: 'CLIProxy was not running (cleaned up stale lock)' };
|
||||
}
|
||||
|
||||
const sessionCount = lock.sessions.length;
|
||||
const pid = lock.pid;
|
||||
|
||||
try {
|
||||
// Kill the proxy process
|
||||
process.kill(pid, 'SIGTERM');
|
||||
|
||||
// Clean up session lock
|
||||
deleteSessionLock();
|
||||
|
||||
return { stopped: true, pid, sessionCount };
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (error.code === 'ESRCH') {
|
||||
// Process already gone
|
||||
deleteSessionLock();
|
||||
return { stopped: false, error: 'CLIProxy process already terminated' };
|
||||
}
|
||||
return { stopped: false, pid, error: `Failed to stop: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get proxy status information.
|
||||
*/
|
||||
export function getProxyStatus(): {
|
||||
running: boolean;
|
||||
port?: number;
|
||||
pid?: number;
|
||||
sessionCount?: number;
|
||||
startedAt?: string;
|
||||
} {
|
||||
const lock = readSessionLock();
|
||||
|
||||
if (!lock) {
|
||||
return { running: false };
|
||||
}
|
||||
|
||||
// Verify proxy is still running
|
||||
if (!isProcessRunning(lock.pid)) {
|
||||
deleteSessionLock();
|
||||
return { running: false };
|
||||
}
|
||||
|
||||
return {
|
||||
running: true,
|
||||
port: lock.port,
|
||||
pid: lock.pid,
|
||||
sessionCount: lock.sessions.length,
|
||||
startedAt: lock.startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
saveUnifiedConfig,
|
||||
} from '../config/unified-config-loader';
|
||||
import { isUnifiedConfigEnabled } from '../config/feature-flags';
|
||||
import { stopProxy, getProxyStatus } from '../cliproxy/session-tracker';
|
||||
|
||||
// ============================================================================
|
||||
// PROFILE MANAGEMENT
|
||||
@@ -804,6 +805,20 @@ async function showHelp(): Promise<void> {
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Proxy Lifecycle Commands
|
||||
console.log(subheader('Proxy Lifecycle:'));
|
||||
const lifecycleCmds: [string, string][] = [
|
||||
['status', 'Show running CLIProxy status'],
|
||||
['stop', 'Stop running CLIProxy instance'],
|
||||
];
|
||||
const maxLifecycleLen = Math.max(...lifecycleCmds.map(([cmd]) => cmd.length));
|
||||
for (const [cmd, desc] of lifecycleCmds) {
|
||||
console.log(` ${color(cmd.padEnd(maxLifecycleLen + 2), 'command')} ${desc}`);
|
||||
}
|
||||
console.log('');
|
||||
console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.'));
|
||||
console.log('');
|
||||
|
||||
// Binary Commands
|
||||
console.log(subheader('Binary Commands:'));
|
||||
const binaryCmds: [string, string][] = [
|
||||
@@ -1041,6 +1056,62 @@ async function installLatest(verbose: boolean): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PROXY LIFECYCLE COMMANDS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Handle 'ccs cliproxy stop' - Stop running CLIProxy instance
|
||||
*/
|
||||
async function handleStop(): Promise<void> {
|
||||
await initUI();
|
||||
|
||||
console.log(header('Stop CLIProxy'));
|
||||
console.log('');
|
||||
|
||||
const result = stopProxy();
|
||||
|
||||
if (result.stopped) {
|
||||
console.log(ok(`CLIProxy stopped (PID ${result.pid})`));
|
||||
if (result.sessionCount && result.sessionCount > 0) {
|
||||
console.log(info(`${result.sessionCount} active session(s) were disconnected`));
|
||||
}
|
||||
} else {
|
||||
console.log(warn(result.error || 'Failed to stop CLIProxy'));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle 'ccs cliproxy status' - Show running proxy status
|
||||
*/
|
||||
async function handleProxyStatus(): Promise<void> {
|
||||
await initUI();
|
||||
|
||||
console.log(header('CLIProxy Status'));
|
||||
console.log('');
|
||||
|
||||
const status = getProxyStatus();
|
||||
|
||||
if (status.running) {
|
||||
console.log(` Status: ${color('Running', 'success')}`);
|
||||
console.log(` PID: ${status.pid}`);
|
||||
console.log(` Port: ${status.port}`);
|
||||
console.log(` Sessions: ${status.sessionCount || 0} active`);
|
||||
if (status.startedAt) {
|
||||
const started = new Date(status.startedAt);
|
||||
console.log(` Started: ${started.toLocaleString()}`);
|
||||
}
|
||||
console.log('');
|
||||
console.log(dim('To stop: ccs cliproxy stop'));
|
||||
} else {
|
||||
console.log(` Status: ${color('Not running', 'warning')}`);
|
||||
console.log('');
|
||||
console.log(dim('CLIProxy starts automatically when you run ccs gemini, codex, etc.'));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN ROUTER
|
||||
// ============================================================================
|
||||
@@ -1074,6 +1145,17 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle proxy lifecycle commands
|
||||
if (command === 'stop') {
|
||||
await handleStop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'status') {
|
||||
await handleProxyStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle --install <version>
|
||||
const installIdx = args.indexOf('--install');
|
||||
if (installIdx !== -1) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import * as path from 'path';
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
|
||||
export interface FileChangeEvent {
|
||||
type: 'config-changed' | 'settings-changed' | 'profiles-changed';
|
||||
type: 'config-changed' | 'settings-changed' | 'profiles-changed' | 'proxy-status-changed';
|
||||
path: string;
|
||||
timestamp: number;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export function createFileWatcher(onChange: FileChangeCallback): FSWatcher {
|
||||
path.join(ccsDir, 'config.json'),
|
||||
path.join(ccsDir, '*.settings.json'),
|
||||
path.join(ccsDir, 'profiles.json'),
|
||||
path.join(ccsDir, 'cliproxy', 'sessions.json'), // Proxy session tracking
|
||||
],
|
||||
{
|
||||
persistent: true,
|
||||
@@ -44,6 +45,8 @@ export function createFileWatcher(onChange: FileChangeCallback): FSWatcher {
|
||||
type = 'config-changed';
|
||||
} else if (basename === 'profiles.json') {
|
||||
type = 'profiles-changed';
|
||||
} else if (basename === 'sessions.json') {
|
||||
type = 'proxy-status-changed';
|
||||
} else {
|
||||
type = 'settings-changed';
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
} from '../cliproxy/account-manager';
|
||||
import type { CLIProxyProvider } from '../cliproxy/types';
|
||||
import { getClaudeEnvVars } from '../cliproxy/config-generator';
|
||||
import { getProxyStatus as getProxyProcessStatus } from '../cliproxy/session-tracker';
|
||||
import { ensureCliproxyService } from '../cliproxy/service-manager';
|
||||
// Unified config imports
|
||||
import {
|
||||
hasUnifiedConfig,
|
||||
@@ -1290,6 +1292,56 @@ apiRoutes.get('/cliproxy/status', async (_req: Request, res: Response): Promise<
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/proxy-status - Get detailed proxy process status
|
||||
* Returns: { running, port?, pid?, sessionCount?, startedAt? }
|
||||
* Combines session tracker data with actual port check for accuracy
|
||||
*/
|
||||
apiRoutes.get('/cliproxy/proxy-status', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// First check session tracker for detailed info
|
||||
const sessionStatus = getProxyProcessStatus();
|
||||
|
||||
// If session tracker says running, trust it
|
||||
if (sessionStatus.running) {
|
||||
res.json(sessionStatus);
|
||||
return;
|
||||
}
|
||||
|
||||
// Session tracker says not running, but proxy might be running without session tracking
|
||||
// (e.g., started before session persistence was implemented)
|
||||
const actuallyRunning = await isCliproxyRunning();
|
||||
|
||||
if (actuallyRunning) {
|
||||
// Proxy running but no session lock - legacy/untracked instance
|
||||
res.json({
|
||||
running: true,
|
||||
port: 8317, // Default port
|
||||
sessionCount: 0, // Unknown sessions
|
||||
// No pid/startedAt since we don't have session lock
|
||||
});
|
||||
} else {
|
||||
res.json(sessionStatus);
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cliproxy/proxy-start - Start the CLIProxy service
|
||||
* Returns: { started, alreadyRunning, port, error? }
|
||||
* Starts proxy in background if not already running
|
||||
*/
|
||||
apiRoutes.post('/cliproxy/proxy-start', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const result = await ensureCliproxyService();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/models - Get available models from CLIProxyAPI
|
||||
* Returns: { models: CliproxyModel[], byCategory: Record<string, CliproxyModel[]>, totalCount: number }
|
||||
|
||||
@@ -21,6 +21,8 @@ const {
|
||||
getSessionCount,
|
||||
hasActiveSessions,
|
||||
cleanupOrphanedSessions,
|
||||
stopProxy,
|
||||
getProxyStatus,
|
||||
} = require('../../../dist/cliproxy/session-tracker');
|
||||
|
||||
describe('Session Tracker', function () {
|
||||
@@ -296,6 +298,97 @@ describe('Session Tracker', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopProxy', function () {
|
||||
it('should return error when no lock exists', function () {
|
||||
const result = stopProxy();
|
||||
assert.strictEqual(result.stopped, false);
|
||||
assert.strictEqual(result.error, 'No active CLIProxy session found');
|
||||
});
|
||||
|
||||
it('should cleanup stale lock when proxy is not running', function () {
|
||||
// Create lock with dead PID
|
||||
const lock = {
|
||||
port: testPort,
|
||||
pid: 999999999, // Very unlikely to exist
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
const result = stopProxy();
|
||||
assert.strictEqual(result.stopped, false);
|
||||
assert.ok(result.error.includes('not running'));
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||
});
|
||||
|
||||
it('should return pid and session count on success', function () {
|
||||
// Register a session with current process
|
||||
registerSession(testPort, process.pid);
|
||||
|
||||
// Note: We can't actually test killing our own process,
|
||||
// but we can verify the structure is correct before it attempts kill
|
||||
const status = getProxyStatus();
|
||||
assert.strictEqual(status.running, true);
|
||||
assert.strictEqual(status.pid, process.pid);
|
||||
assert.strictEqual(status.sessionCount, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProxyStatus', function () {
|
||||
it('should return not running when no lock exists', function () {
|
||||
const result = getProxyStatus();
|
||||
assert.strictEqual(result.running, false);
|
||||
assert.strictEqual(result.port, undefined);
|
||||
assert.strictEqual(result.pid, undefined);
|
||||
});
|
||||
|
||||
it('should return full status when proxy is running', function () {
|
||||
const startedAt = new Date().toISOString();
|
||||
const lock = {
|
||||
port: testPort,
|
||||
pid: process.pid, // Current process - alive
|
||||
sessions: ['session1', 'session2'],
|
||||
startedAt,
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
const result = getProxyStatus();
|
||||
assert.strictEqual(result.running, true);
|
||||
assert.strictEqual(result.port, testPort);
|
||||
assert.strictEqual(result.pid, process.pid);
|
||||
assert.strictEqual(result.sessionCount, 2);
|
||||
assert.strictEqual(result.startedAt, startedAt);
|
||||
});
|
||||
|
||||
it('should cleanup and return not running when proxy is dead', function () {
|
||||
const lock = {
|
||||
port: testPort,
|
||||
pid: 999999999, // Dead PID
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
const result = getProxyStatus();
|
||||
assert.strictEqual(result.running, false);
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||
});
|
||||
|
||||
it('should return correct session count after registrations', function () {
|
||||
registerSession(testPort, process.pid);
|
||||
let status = getProxyStatus();
|
||||
assert.strictEqual(status.sessionCount, 1);
|
||||
|
||||
registerSession(testPort, process.pid);
|
||||
status = getProxyStatus();
|
||||
assert.strictEqual(status.sessionCount, 2);
|
||||
|
||||
registerSession(testPort, process.pid);
|
||||
status = getProxyStatus();
|
||||
assert.strictEqual(status.sessionCount, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multi-session scenario', function () {
|
||||
it('should handle complete multi-terminal workflow', function () {
|
||||
// Terminal 1 starts - first session
|
||||
|
||||
@@ -357,3 +357,156 @@ describe('API Command - Model Fields Fix', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLIProxy Command - Proxy Lifecycle', () => {
|
||||
// Test isolation environment
|
||||
let testHome;
|
||||
let sessionLockPath;
|
||||
|
||||
beforeEach(() => {
|
||||
testHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-lifecycle-'));
|
||||
process.env.CCS_HOME = testHome;
|
||||
const cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
|
||||
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||
sessionLockPath = path.join(cliproxyDir, 'sessions.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (testHome && fs.existsSync(testHome)) {
|
||||
fs.rmSync(testHome, { recursive: true, force: true });
|
||||
}
|
||||
delete process.env.CCS_HOME;
|
||||
});
|
||||
|
||||
describe('Status Command Logic', () => {
|
||||
it('returns not running when no session lock exists', () => {
|
||||
// Simulate getProxyStatus behavior
|
||||
const lockExists = fs.existsSync(sessionLockPath);
|
||||
assert.strictEqual(lockExists, false);
|
||||
|
||||
// Status should indicate not running
|
||||
const status = { running: false };
|
||||
assert.strictEqual(status.running, false);
|
||||
});
|
||||
|
||||
it('returns running with details when session lock exists', () => {
|
||||
// Create mock session lock
|
||||
const lock = {
|
||||
port: 8317,
|
||||
pid: process.pid,
|
||||
sessions: ['session1', 'session2'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock, null, 2));
|
||||
|
||||
// Read and verify
|
||||
const data = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8'));
|
||||
assert.strictEqual(data.port, 8317);
|
||||
assert.strictEqual(data.pid, process.pid);
|
||||
assert.strictEqual(data.sessions.length, 2);
|
||||
assert(data.startedAt);
|
||||
});
|
||||
|
||||
it('formats uptime correctly', () => {
|
||||
// Test uptime formatting logic
|
||||
const formatUptime = (ms) => {
|
||||
const hours = Math.floor(ms / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
assert.strictEqual(formatUptime(30 * 60 * 1000), '30m'); // 30 minutes
|
||||
assert.strictEqual(formatUptime(90 * 60 * 1000), '1h 30m'); // 1.5 hours
|
||||
assert.strictEqual(formatUptime(2 * 60 * 60 * 1000 + 15 * 60 * 1000), '2h 15m'); // 2h 15m
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stop Command Logic', () => {
|
||||
it('returns error when no session lock exists', () => {
|
||||
// Verify lock doesn't exist
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||
|
||||
// Stop should fail with appropriate error
|
||||
const result = { stopped: false, error: 'No active CLIProxy session found' };
|
||||
assert.strictEqual(result.stopped, false);
|
||||
assert.strictEqual(result.error, 'No active CLIProxy session found');
|
||||
});
|
||||
|
||||
it('cleans up stale lock for dead process', () => {
|
||||
// Create lock with non-existent PID
|
||||
const lock = {
|
||||
port: 8317,
|
||||
pid: 999999999, // Very unlikely to exist
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock, null, 2));
|
||||
|
||||
// Verify lock was created
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), true);
|
||||
|
||||
// Simulate isProcessRunning check
|
||||
const isRunning = (() => {
|
||||
try {
|
||||
process.kill(999999999, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
assert.strictEqual(isRunning, false);
|
||||
|
||||
// Cleanup should remove lock
|
||||
if (!isRunning) {
|
||||
fs.unlinkSync(sessionLockPath);
|
||||
}
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||
});
|
||||
|
||||
it('returns session count when stopping active proxy', () => {
|
||||
// Create lock with current process
|
||||
const lock = {
|
||||
port: 8317,
|
||||
pid: process.pid,
|
||||
sessions: ['session1', 'session2', 'session3'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock, null, 2));
|
||||
|
||||
// Read and verify session count
|
||||
const data = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8'));
|
||||
assert.strictEqual(data.sessions.length, 3);
|
||||
|
||||
// Result structure should include count
|
||||
const result = {
|
||||
stopped: true,
|
||||
pid: data.pid,
|
||||
sessionCount: data.sessions.length,
|
||||
};
|
||||
assert.strictEqual(result.stopped, true);
|
||||
assert.strictEqual(result.sessionCount, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Command Routing', () => {
|
||||
it('routes "stop" subcommand correctly', () => {
|
||||
const args = ['cliproxy', 'stop'];
|
||||
const subcommand = args[1];
|
||||
assert.strictEqual(subcommand, 'stop');
|
||||
});
|
||||
|
||||
it('routes "status" subcommand correctly', () => {
|
||||
const args = ['cliproxy', 'status'];
|
||||
const subcommand = args[1];
|
||||
assert.strictEqual(subcommand, 'status');
|
||||
});
|
||||
|
||||
it('handles unknown subcommand', () => {
|
||||
const validSubcommands = ['stop', 'status', 'create', 'list', 'remove'];
|
||||
const unknownCommand = 'invalid';
|
||||
assert.strictEqual(validSubcommands.includes(unknownCommand), false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Proxy Status Widget
|
||||
*
|
||||
* Displays CLIProxy process status with start button for recovery.
|
||||
* Shows: running state, port, session count, uptime.
|
||||
*/
|
||||
|
||||
import { Activity, Power, RefreshCw, Clock, Users } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useProxyStatus, useStartProxy } from '@/hooks/use-cliproxy';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function formatUptime(startedAt?: string): string {
|
||||
if (!startedAt) return '';
|
||||
const start = new Date(startedAt).getTime();
|
||||
const now = Date.now();
|
||||
const diff = now - start;
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
export function ProxyStatusWidget() {
|
||||
const { data: status, isLoading } = useProxyStatus();
|
||||
const startProxy = useStartProxy();
|
||||
|
||||
const isRunning = status?.running ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-3 transition-colors',
|
||||
isRunning ? 'border-green-500/30 bg-green-500/5' : 'border-muted bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
isRunning ? 'bg-green-500 animate-pulse' : 'bg-muted-foreground/30'
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">CLIProxy Service</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{isLoading ? (
|
||||
<RefreshCw className="w-3 h-3 animate-spin text-muted-foreground" />
|
||||
) : isRunning ? (
|
||||
<Activity className="w-3 h-3 text-green-600" />
|
||||
) : (
|
||||
<Power className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isRunning && status ? (
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">Port {status.port}</span>
|
||||
{status.sessionCount !== undefined && status.sessionCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{status.startedAt && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatUptime(status.startedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">Not running</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1"
|
||||
onClick={() => startProxy.mutate()}
|
||||
disabled={startProxy.isPending}
|
||||
>
|
||||
{startProxy.isPending ? (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Power className="w-3 h-3" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -209,3 +209,34 @@ export function useDeletePreset() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Proxy Process Status ====================
|
||||
|
||||
export function useProxyStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['proxy-status'],
|
||||
queryFn: () => api.cliproxy.proxyStatus(),
|
||||
refetchInterval: 30000, // Refresh every 30s as backup (websocket is primary)
|
||||
});
|
||||
}
|
||||
|
||||
export function useStartProxy() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => api.cliproxy.proxyStart(),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['proxy-status'] });
|
||||
if (data.alreadyRunning) {
|
||||
toast.info('CLIProxy was already running');
|
||||
} else if (data.started) {
|
||||
toast.success('CLIProxy started successfully');
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to start CLIProxy');
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ export function useWebSocket() {
|
||||
toast.info('Accounts updated');
|
||||
break;
|
||||
|
||||
case 'proxy-status-changed':
|
||||
queryClient.invalidateQueries({ queryKey: ['proxy-status'] });
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
// Heartbeat response
|
||||
break;
|
||||
|
||||
@@ -154,6 +154,24 @@ export interface CreatePreset {
|
||||
haiku?: string;
|
||||
}
|
||||
|
||||
/** CLIProxy process status from session tracker */
|
||||
export interface ProxyProcessStatus {
|
||||
running: boolean;
|
||||
port?: number;
|
||||
pid?: number;
|
||||
sessionCount?: number;
|
||||
startedAt?: string;
|
||||
}
|
||||
|
||||
/** Result from starting proxy service */
|
||||
export interface ProxyStartResult {
|
||||
started: boolean;
|
||||
alreadyRunning: boolean;
|
||||
port: number;
|
||||
configRegenerated?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// API
|
||||
export const api = {
|
||||
profiles: {
|
||||
@@ -185,6 +203,10 @@ export const api = {
|
||||
}),
|
||||
delete: (name: string) => request(`/cliproxy/${name}`, { method: 'DELETE' }),
|
||||
|
||||
// Proxy process status and control
|
||||
proxyStatus: () => request<ProxyProcessStatus>('/cliproxy/proxy-status'),
|
||||
proxyStart: () => request<ProxyStartResult>('/cliproxy/proxy-start', { method: 'POST' }),
|
||||
|
||||
// Stats and models for Overview tab
|
||||
stats: () => request<{ usage: Record<string, unknown> }>('/cliproxy/usage'),
|
||||
models: () => request<CliproxyModelsResponse>('/cliproxy/models'),
|
||||
|
||||
@@ -15,6 +15,7 @@ import { QuickSetupWizard } from '@/components/quick-setup-wizard';
|
||||
import { AddAccountDialog } from '@/components/add-account-dialog';
|
||||
import { ProviderEditor } from '@/components/cliproxy/provider-editor';
|
||||
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||
import { ProxyStatusWidget } from '@/components/proxy-status-widget';
|
||||
import {
|
||||
useCliproxy,
|
||||
useCliproxyAuth,
|
||||
@@ -307,6 +308,11 @@ export function CliproxyPage() {
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Proxy Status Widget */}
|
||||
<div className="p-3 border-t">
|
||||
<ProxyStatusWidget />
|
||||
</div>
|
||||
|
||||
{/* Footer Stats */}
|
||||
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
Reference in New Issue
Block a user