mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 00:17:47 +00:00
feat(logging): unify CCS runtime logs and dashboard viewer
- add a shared structured logging service with bounded retention - expose native /api/logs endpoints and the dashboard /logs route - wire focused runtime emitters, cleanup support, and feature tests Refs #926
This commit is contained in:
+11
@@ -68,6 +68,7 @@ import { tryHandleRootCommand } from './commands/root-command-router';
|
|||||||
import { execClaude } from './utils/shell-executor';
|
import { execClaude } from './utils/shell-executor';
|
||||||
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
|
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
|
||||||
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
|
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
|
||||||
|
import { createLogger } from './services/logging';
|
||||||
|
|
||||||
// Import target adapter system
|
// Import target adapter system
|
||||||
import {
|
import {
|
||||||
@@ -321,6 +322,7 @@ async function main(): Promise<void> {
|
|||||||
registerTarget(new ClaudeAdapter());
|
registerTarget(new ClaudeAdapter());
|
||||||
registerTarget(new DroidAdapter());
|
registerTarget(new DroidAdapter());
|
||||||
registerTarget(new CodexAdapter());
|
registerTarget(new CodexAdapter());
|
||||||
|
const cliLogger = createLogger('cli');
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const isCompletionCommand = args[0] === '__complete';
|
const isCompletionCommand = args[0] === '__complete';
|
||||||
@@ -398,6 +400,12 @@ async function main(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cliLogger.info('command.start', 'CLI invocation started', {
|
||||||
|
command: args[0] || 'default',
|
||||||
|
argCount: args.length,
|
||||||
|
flags: args.filter((arg) => arg.startsWith('-')).slice(0, 20),
|
||||||
|
});
|
||||||
|
|
||||||
if (shouldPassthroughNativeCodexFlagCommand(args)) {
|
if (shouldPassthroughNativeCodexFlagCommand(args)) {
|
||||||
execNativeCodexFlagCommand(args);
|
execNativeCodexFlagCommand(args);
|
||||||
return;
|
return;
|
||||||
@@ -448,6 +456,9 @@ async function main(): Promise<void> {
|
|||||||
recovery.showRecoveryHints();
|
recovery.showRecoveryHints();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
cliLogger.warn('recovery.failed', 'Auto-recovery failed during CLI startup', {
|
||||||
|
message: (err as Error).message,
|
||||||
|
});
|
||||||
// Recovery is best-effort - don't block basic CLI functionality
|
// Recovery is best-effort - don't block basic CLI functionality
|
||||||
console.warn('[!] Recovery failed:', (err as Error).message);
|
console.warn('[!] Recovery failed:', (err as Error).message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { getExistingProxy, registerSession, getRunningProxyVersion } from './ses
|
|||||||
import { isCliproxyRunning } from './stats-fetcher';
|
import { isCliproxyRunning } from './stats-fetcher';
|
||||||
import { getPortProcess, isCLIProxyProcess, PortProcess } from '../utils/port-utils';
|
import { getPortProcess, isCLIProxyProcess, PortProcess } from '../utils/port-utils';
|
||||||
import { CLIPROXY_DEFAULT_PORT } from './config-generator';
|
import { CLIPROXY_DEFAULT_PORT } from './config-generator';
|
||||||
|
import { createLogger } from '../services/logging';
|
||||||
|
|
||||||
/** Detection method used to find the proxy */
|
/** Detection method used to find the proxy */
|
||||||
export type DetectionMethod = 'http' | 'session-lock' | 'port-process' | 'http-retry';
|
export type DetectionMethod = 'http' | 'session-lock' | 'port-process' | 'http-retry';
|
||||||
@@ -48,6 +49,7 @@ type LogFn = (msg: string) => void;
|
|||||||
|
|
||||||
/** No-op logger for when verbose is disabled */
|
/** No-op logger for when verbose is disabled */
|
||||||
const noopLog: LogFn = () => {};
|
const noopLog: LogFn = () => {};
|
||||||
|
const logger = createLogger('cliproxy:proxy-detector');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect running CLIProxy using multiple methods with fallbacks.
|
* Detect running CLIProxy using multiple methods with fallbacks.
|
||||||
@@ -65,7 +67,7 @@ export async function detectRunningProxy(
|
|||||||
port: number = CLIPROXY_DEFAULT_PORT,
|
port: number = CLIPROXY_DEFAULT_PORT,
|
||||||
verbose: boolean = false
|
verbose: boolean = false
|
||||||
): Promise<ProxyStatus> {
|
): Promise<ProxyStatus> {
|
||||||
const log: LogFn = verbose ? (msg) => console.error(`[proxy-detector] ${msg}`) : noopLog;
|
const log: LogFn = verbose ? (msg) => logger.debug('detect.verbose', msg, { port }) : noopLog;
|
||||||
|
|
||||||
// Validate port - fallback to default if invalid
|
// Validate port - fallback to default if invalid
|
||||||
const validPort =
|
const validPort =
|
||||||
@@ -235,7 +237,9 @@ export function reclaimOrphanedProxy(
|
|||||||
pid: number,
|
pid: number,
|
||||||
verbose: boolean = false
|
verbose: boolean = false
|
||||||
): string | null {
|
): string | null {
|
||||||
const log: LogFn = verbose ? (msg) => console.error(`[proxy-detector] ${msg}`) : noopLog;
|
const log: LogFn = verbose
|
||||||
|
? (msg) => logger.debug('reclaim.verbose', msg, { port, pid })
|
||||||
|
: noopLog;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log(`Reclaiming orphaned proxy: port=${port}, pid=${pid}`);
|
log(`Reclaiming orphaned proxy: port=${port}, pid=${pid}`);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { getCliproxyDir } from './config-generator';
|
import { getCliproxyDir } from './config-generator';
|
||||||
|
import { createLogger } from '../services/logging';
|
||||||
|
|
||||||
/** Lock file structure */
|
/** Lock file structure */
|
||||||
interface LockData {
|
interface LockData {
|
||||||
@@ -51,6 +52,7 @@ type LogFn = (msg: string) => void;
|
|||||||
|
|
||||||
/** No-op logger for when verbose is disabled */
|
/** No-op logger for when verbose is disabled */
|
||||||
const noopLog: LogFn = () => {};
|
const noopLog: LogFn = () => {};
|
||||||
|
const logger = createLogger('cliproxy:startup-lock');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get path to startup lock file
|
* Get path to startup lock file
|
||||||
@@ -184,7 +186,9 @@ export async function acquireStartupLock(options?: {
|
|||||||
}): Promise<LockResult> {
|
}): Promise<LockResult> {
|
||||||
const retries = options?.retries ?? 20;
|
const retries = options?.retries ?? 20;
|
||||||
const retryInterval = options?.retryInterval ?? 250;
|
const retryInterval = options?.retryInterval ?? 250;
|
||||||
const log: LogFn = options?.verbose ? (msg) => console.error(`[startup-lock] ${msg}`) : noopLog;
|
const log: LogFn = options?.verbose
|
||||||
|
? (msg) => logger.debug('lock.verbose', msg, { retries, retryInterval })
|
||||||
|
: noopLog;
|
||||||
|
|
||||||
log(`Attempting to acquire startup lock (max ${retries} retries, ${retryInterval}ms interval)`);
|
log(`Attempting to acquire startup lock (max ${retries} retries, ${retryInterval}ms interval)`);
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from './model-id-normalizer';
|
} from './model-id-normalizer';
|
||||||
import { getModelMaxLevel } from './model-catalog';
|
import { getModelMaxLevel } from './model-catalog';
|
||||||
import { getCcsDir } from '../utils/config-manager';
|
import { getCcsDir } from '../utils/config-manager';
|
||||||
|
import { createLogger } from '../services/logging';
|
||||||
|
|
||||||
export interface ToolSanitizationProxyConfig {
|
export interface ToolSanitizationProxyConfig {
|
||||||
/** Upstream CLIProxy URL */
|
/** Upstream CLIProxy URL */
|
||||||
@@ -153,6 +154,7 @@ export class ToolSanitizationProxy {
|
|||||||
private readonly config: Required<ToolSanitizationProxyConfig>;
|
private readonly config: Required<ToolSanitizationProxyConfig>;
|
||||||
private readonly logFilePath: string;
|
private readonly logFilePath: string;
|
||||||
private readonly debugMode: boolean;
|
private readonly debugMode: boolean;
|
||||||
|
private readonly logger = createLogger('cliproxy:tool-sanitization-proxy');
|
||||||
|
|
||||||
constructor(config: ToolSanitizationProxyConfig) {
|
constructor(config: ToolSanitizationProxyConfig) {
|
||||||
this.config = {
|
this.config = {
|
||||||
@@ -207,6 +209,11 @@ export class ToolSanitizationProxy {
|
|||||||
if (this.debugMode) {
|
if (this.debugMode) {
|
||||||
console.error(`${prefix} ${message}`);
|
console.error(`${prefix} ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logger[level](level, message, {
|
||||||
|
debugMode: this.debugMode,
|
||||||
|
logFilePath: this.logFilePath,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private log(message: string): void {
|
private log(message: string): void {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { getCliproxyDir } from '../cliproxy/config-generator';
|
import { getCliproxyDir } from '../cliproxy/config-generator';
|
||||||
|
import { getNativeLogsDir } from '../services/logging';
|
||||||
import { info, ok, warn } from '../utils/ui';
|
import { info, ok, warn } from '../utils/ui';
|
||||||
|
|
||||||
/** Default age in days for error log cleanup */
|
/** Default age in days for error log cleanup */
|
||||||
@@ -19,6 +20,10 @@ function getLogsDir(): string {
|
|||||||
return path.join(getCliproxyDir(), 'logs');
|
return path.join(getCliproxyDir(), 'logs');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCcsLogsDir(): string {
|
||||||
|
return getNativeLogsDir();
|
||||||
|
}
|
||||||
|
|
||||||
/** Format bytes to human-readable size */
|
/** Format bytes to human-readable size */
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
@@ -174,18 +179,18 @@ function printHelp(): void {
|
|||||||
console.log('');
|
console.log('');
|
||||||
console.log('Usage: ccs cleanup [options]');
|
console.log('Usage: ccs cleanup [options]');
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('Remove old CLIProxy logs to free up disk space.');
|
console.log('Remove old CCS and CLIProxy logs to free up disk space.');
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('Options:');
|
console.log('Options:');
|
||||||
console.log(' --errors Clean error request logs (error-*.log files)');
|
console.log(' --errors Clean legacy CLIProxy error request logs (error-*.log files)');
|
||||||
console.log(' --days=N Delete error logs older than N days (default: 7)');
|
console.log(' --days=N Delete error logs older than N days (default: 7)');
|
||||||
console.log(' --dry-run Show what would be deleted without deleting');
|
console.log(' --dry-run Show what would be deleted without deleting');
|
||||||
console.log(' --force Skip confirmation prompt');
|
console.log(' --force Skip confirmation prompt');
|
||||||
console.log(' --help, -h Show this help message');
|
console.log(' --help, -h Show this help message');
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('Examples:');
|
console.log('Examples:');
|
||||||
console.log(' ccs cleanup Interactive main log cleanup');
|
console.log(' ccs cleanup Interactive CCS + CLIProxy log cleanup');
|
||||||
console.log(' ccs cleanup --errors Clean error logs older than 7 days');
|
console.log(' ccs cleanup --errors Clean legacy CLIProxy error logs older than 7 days');
|
||||||
console.log(' ccs cleanup --errors --days=3 Clean error logs older than 3 days');
|
console.log(' ccs cleanup --errors --days=3 Clean error logs older than 3 days');
|
||||||
console.log(' ccs cleanup --errors --dry-run Preview error log cleanup');
|
console.log(' ccs cleanup --errors --dry-run Preview error log cleanup');
|
||||||
console.log(' ccs cleanup --dry-run Preview main log cleanup');
|
console.log(' ccs cleanup --dry-run Preview main log cleanup');
|
||||||
@@ -207,6 +212,7 @@ export async function handleCleanupCommand(args: string[]): Promise<void> {
|
|||||||
const force = args.includes('--force');
|
const force = args.includes('--force');
|
||||||
const cleanErrors = args.includes('--errors');
|
const cleanErrors = args.includes('--errors');
|
||||||
const logsDir = getLogsDir();
|
const logsDir = getLogsDir();
|
||||||
|
const ccsLogsDir = getCcsLogsDir();
|
||||||
|
|
||||||
// Parse --days=N option
|
// Parse --days=N option
|
||||||
let maxAgeDays = DEFAULT_ERROR_LOG_AGE_DAYS;
|
let maxAgeDays = DEFAULT_ERROR_LOG_AGE_DAYS;
|
||||||
@@ -224,7 +230,7 @@ export async function handleCleanupCommand(args: string[]): Promise<void> {
|
|||||||
if (cleanErrors) {
|
if (cleanErrors) {
|
||||||
await handleErrorLogCleanup(logsDir, maxAgeDays, dryRun, force);
|
await handleErrorLogCleanup(logsDir, maxAgeDays, dryRun, force);
|
||||||
} else {
|
} else {
|
||||||
await handleMainLogCleanup(logsDir, dryRun, force);
|
await handleMainLogCleanup({ cliproxyLogsDir: logsDir, ccsLogsDir, dryRun, force });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,40 +325,46 @@ async function handleErrorLogCleanup(
|
|||||||
/**
|
/**
|
||||||
* Handle main log cleanup (main.log and rotated files)
|
* Handle main log cleanup (main.log and rotated files)
|
||||||
*/
|
*/
|
||||||
async function handleMainLogCleanup(
|
async function handleMainLogCleanup(options: {
|
||||||
logsDir: string,
|
cliproxyLogsDir: string;
|
||||||
dryRun: boolean,
|
ccsLogsDir: string;
|
||||||
force: boolean
|
dryRun: boolean;
|
||||||
): Promise<void> {
|
force: boolean;
|
||||||
// Check if logs directory exists
|
}): Promise<void> {
|
||||||
if (!fs.existsSync(logsDir)) {
|
const targets = [
|
||||||
console.log(info('No CLIProxy logs found.'));
|
{ label: 'CCS Logs', dir: options.ccsLogsDir },
|
||||||
|
{ label: 'CLIProxy Logs', dir: options.cliproxyLogsDir },
|
||||||
|
].map((target) => ({
|
||||||
|
...target,
|
||||||
|
fileCount: countFiles(target.dir),
|
||||||
|
size: getDirSize(target.dir),
|
||||||
|
}));
|
||||||
|
const activeTargets = targets.filter((target) => target.fileCount > 0);
|
||||||
|
|
||||||
|
if (activeTargets.length === 0) {
|
||||||
|
console.log(info('No CCS or CLIProxy logs found.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate current size
|
const currentSize = activeTargets.reduce((sum, target) => sum + target.size, 0);
|
||||||
const currentSize = getDirSize(logsDir);
|
const fileCount = activeTargets.reduce((sum, target) => sum + target.fileCount, 0);
|
||||||
const fileCount = countFiles(logsDir);
|
|
||||||
|
|
||||||
if (fileCount === 0) {
|
console.log('');
|
||||||
console.log(info('No log files to clean.'));
|
console.log('Log Cleanup Targets:');
|
||||||
return;
|
for (const target of activeTargets) {
|
||||||
|
console.log(` ${target.label}: ${target.fileCount} files (${formatBytes(target.size)})`);
|
||||||
|
console.log(` ${target.dir}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('');
|
|
||||||
console.log(`CLIProxy Logs: ${logsDir}`);
|
|
||||||
console.log(` Files: ${fileCount}`);
|
|
||||||
console.log(` Size: ${formatBytes(currentSize)}`);
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
if (dryRun) {
|
if (options.dryRun) {
|
||||||
console.log(info('Dry run - no files deleted.'));
|
console.log(info('Dry run - no files deleted.'));
|
||||||
console.log(`Would delete ${fileCount} files (${formatBytes(currentSize)})`);
|
console.log(`Would delete ${fileCount} files (${formatBytes(currentSize)})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm unless --force
|
// Confirm unless --force
|
||||||
if (!force) {
|
if (!options.force) {
|
||||||
const readline = await import('readline');
|
const readline = await import('readline');
|
||||||
const rl = readline.createInterface({
|
const rl = readline.createInterface({
|
||||||
input: process.stdin,
|
input: process.stdin,
|
||||||
@@ -371,13 +383,19 @@ async function handleMainLogCleanup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Perform cleanup
|
// Perform cleanup
|
||||||
const { deleted, freedBytes } = cleanDirectory(logsDir);
|
let deleted = 0;
|
||||||
|
let freedBytes = 0;
|
||||||
|
for (const target of activeTargets) {
|
||||||
|
const result = cleanDirectory(target.dir);
|
||||||
|
deleted += result.deleted;
|
||||||
|
freedBytes += result.freedBytes;
|
||||||
|
}
|
||||||
console.log(ok(`Deleted ${deleted} files, freed ${formatBytes(freedBytes)}`));
|
console.log(ok(`Deleted ${deleted} files, freed ${formatBytes(freedBytes)}`));
|
||||||
|
|
||||||
// Suggest disabling logging if it was enabled
|
// Suggest disabling logging if it was enabled
|
||||||
if (deleted > 0) {
|
if (deleted > 0) {
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(warn('Tip: CLIProxy logging is now disabled by default.'));
|
console.log(warn('Tip: CCS logging is bounded by retention, but you can lower it further.'));
|
||||||
console.log(' Run `ccs doctor --fix` to update your config.');
|
console.log(' Open `ccs config` and review the Logs settings.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ import {
|
|||||||
resolveDashboardUrls,
|
resolveDashboardUrls,
|
||||||
} from './config-dashboard-host';
|
} from './config-dashboard-host';
|
||||||
import { parseConfigCommandArgs, showConfigCommandHelp } from './config-command-options';
|
import { parseConfigCommandArgs, showConfigCommandHelp } from './config-command-options';
|
||||||
|
import { createLogger } from '../services/logging';
|
||||||
|
|
||||||
|
const logger = createLogger('command:config');
|
||||||
|
|
||||||
const CONFIG_SUBCOMMAND_ROUTES: readonly NamedCommandRoute[] = [
|
const CONFIG_SUBCOMMAND_ROUTES: readonly NamedCommandRoute[] = [
|
||||||
{
|
{
|
||||||
@@ -123,6 +126,11 @@ export async function handleConfigCommand(
|
|||||||
|
|
||||||
const options = parsed.options;
|
const options = parsed.options;
|
||||||
const verbose = options.dev;
|
const verbose = options.dev;
|
||||||
|
logger.info('dashboard.launch_requested', 'Config dashboard launch requested', {
|
||||||
|
dev: Boolean(options.dev),
|
||||||
|
host: options.host || null,
|
||||||
|
port: options.port || null,
|
||||||
|
});
|
||||||
|
|
||||||
console.log(deps.header('CCS Config Dashboard'));
|
console.log(deps.header('CCS Config Dashboard'));
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -130,6 +138,13 @@ export async function handleConfigCommand(
|
|||||||
// Ensure CLIProxy service is running for dashboard features
|
// Ensure CLIProxy service is running for dashboard features
|
||||||
console.log(deps.info('Starting CLIProxy service...'));
|
console.log(deps.info('Starting CLIProxy service...'));
|
||||||
const cliproxyResult = await deps.ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose);
|
const cliproxyResult = await deps.ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose);
|
||||||
|
logger.info('cliproxy.ensure_result', 'Config command checked CLIProxy availability', {
|
||||||
|
started: cliproxyResult.started,
|
||||||
|
alreadyRunning: cliproxyResult.alreadyRunning,
|
||||||
|
configRegenerated: cliproxyResult.configRegenerated,
|
||||||
|
port: cliproxyResult.port || null,
|
||||||
|
error: cliproxyResult.error || null,
|
||||||
|
});
|
||||||
|
|
||||||
if (cliproxyResult.started) {
|
if (cliproxyResult.started) {
|
||||||
if (cliproxyResult.alreadyRunning) {
|
if (cliproxyResult.alreadyRunning) {
|
||||||
@@ -210,14 +225,23 @@ export async function handleConfigCommand(
|
|||||||
// Open browser
|
// Open browser
|
||||||
try {
|
try {
|
||||||
await deps.openBrowser(urls.browserUrl, { wait: false });
|
await deps.openBrowser(urls.browserUrl, { wait: false });
|
||||||
|
logger.info('dashboard.browser_opened', 'Config dashboard browser launch attempted', {
|
||||||
|
browserUrl: urls.browserUrl,
|
||||||
|
});
|
||||||
console.log(deps.info('Browser opened automatically'));
|
console.log(deps.info('Browser opened automatically'));
|
||||||
} catch {
|
} catch {
|
||||||
|
logger.warn('dashboard.browser_open_failed', 'Automatic browser launch failed', {
|
||||||
|
browserUrl: urls.browserUrl,
|
||||||
|
});
|
||||||
console.log(deps.info(`Open manually: ${urls.browserUrl}`));
|
console.log(deps.info(`Open manually: ${urls.browserUrl}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(deps.info('Press Ctrl+C to stop'));
|
console.log(deps.info('Press Ctrl+C to stop'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error('dashboard.launch_failed', 'Config dashboard failed to launch', {
|
||||||
|
message: (error as Error).message,
|
||||||
|
});
|
||||||
console.error(deps.fail(`Failed to start server: ${(error as Error).message}`));
|
console.error(deps.fail(`Failed to start server: ${(error as Error).message}`));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
DEFAULT_OFFICIAL_CHANNELS_CONFIG,
|
DEFAULT_OFFICIAL_CHANNELS_CONFIG,
|
||||||
DEFAULT_DASHBOARD_AUTH_CONFIG,
|
DEFAULT_DASHBOARD_AUTH_CONFIG,
|
||||||
DEFAULT_IMAGE_ANALYSIS_CONFIG,
|
DEFAULT_IMAGE_ANALYSIS_CONFIG,
|
||||||
|
DEFAULT_LOGGING_CONFIG,
|
||||||
} from './unified-config-types';
|
} from './unified-config-types';
|
||||||
import type {
|
import type {
|
||||||
UnifiedConfig,
|
UnifiedConfig,
|
||||||
@@ -34,6 +35,7 @@ import type {
|
|||||||
OfficialChannelId,
|
OfficialChannelId,
|
||||||
DashboardAuthConfig,
|
DashboardAuthConfig,
|
||||||
ImageAnalysisConfig,
|
ImageAnalysisConfig,
|
||||||
|
LoggingConfig,
|
||||||
CursorConfig,
|
CursorConfig,
|
||||||
ContinuityConfig,
|
ContinuityConfig,
|
||||||
} from './unified-config-types';
|
} from './unified-config-types';
|
||||||
@@ -381,6 +383,15 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
|||||||
: defaults.cliproxy.routing?.strategy,
|
: defaults.cliproxy.routing?.strategy,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
logging: {
|
||||||
|
enabled: partial.logging?.enabled ?? DEFAULT_LOGGING_CONFIG.enabled,
|
||||||
|
level: partial.logging?.level ?? DEFAULT_LOGGING_CONFIG.level,
|
||||||
|
rotate_mb: partial.logging?.rotate_mb ?? DEFAULT_LOGGING_CONFIG.rotate_mb,
|
||||||
|
retain_days: partial.logging?.retain_days ?? DEFAULT_LOGGING_CONFIG.retain_days,
|
||||||
|
redact: partial.logging?.redact ?? DEFAULT_LOGGING_CONFIG.redact,
|
||||||
|
live_buffer_size:
|
||||||
|
partial.logging?.live_buffer_size ?? DEFAULT_LOGGING_CONFIG.live_buffer_size,
|
||||||
|
},
|
||||||
preferences: {
|
preferences: {
|
||||||
...defaults.preferences,
|
...defaults.preferences,
|
||||||
...partial.preferences,
|
...partial.preferences,
|
||||||
@@ -657,6 +668,19 @@ function generateYamlWithComments(config: UnifiedConfig): string {
|
|||||||
);
|
);
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
||||||
|
if (config.logging) {
|
||||||
|
lines.push('# ----------------------------------------------------------------------------');
|
||||||
|
lines.push('# Logging: CCS-owned structured runtime logs');
|
||||||
|
lines.push('# Current file: ~/.ccs/logs/current.jsonl');
|
||||||
|
lines.push('# Archives rotate automatically and are pruned by retain_days.');
|
||||||
|
lines.push('# This is separate from cliproxy.logging, which controls CLIProxy runtime files.');
|
||||||
|
lines.push('# ----------------------------------------------------------------------------');
|
||||||
|
lines.push(
|
||||||
|
yaml.dump({ logging: config.logging }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
|
||||||
|
);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
// CLIProxy Server section (remote proxy configuration) - placed right after cliproxy
|
// CLIProxy Server section (remote proxy configuration) - placed right after cliproxy
|
||||||
if (config.cliproxy_server) {
|
if (config.cliproxy_server) {
|
||||||
lines.push('# ----------------------------------------------------------------------------');
|
lines.push('# ----------------------------------------------------------------------------');
|
||||||
@@ -1291,6 +1315,19 @@ export function getImageAnalysisConfig(): ImageAnalysisConfig {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLoggingConfig(): LoggingConfig {
|
||||||
|
const config = loadOrCreateUnifiedConfig();
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: config.logging?.enabled ?? DEFAULT_LOGGING_CONFIG.enabled,
|
||||||
|
level: config.logging?.level ?? DEFAULT_LOGGING_CONFIG.level,
|
||||||
|
rotate_mb: config.logging?.rotate_mb ?? DEFAULT_LOGGING_CONFIG.rotate_mb,
|
||||||
|
retain_days: config.logging?.retain_days ?? DEFAULT_LOGGING_CONFIG.retain_days,
|
||||||
|
redact: config.logging?.redact ?? DEFAULT_LOGGING_CONFIG.redact,
|
||||||
|
live_buffer_size: config.logging?.live_buffer_size ?? DEFAULT_LOGGING_CONFIG.live_buffer_size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cursor configuration.
|
* Get cursor configuration.
|
||||||
* Returns defaults if not configured.
|
* Returns defaults if not configured.
|
||||||
|
|||||||
@@ -234,6 +234,36 @@ export interface CLIProxyConfig {
|
|||||||
routing?: CLIProxyRoutingConfig;
|
routing?: CLIProxyRoutingConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LoggingLevel = 'error' | 'warn' | 'info' | 'debug';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CCS-owned structured logging configuration.
|
||||||
|
* Separate from cliproxy.logging, which controls CLIProxy runtime files.
|
||||||
|
*/
|
||||||
|
export interface LoggingConfig {
|
||||||
|
/** Enable CCS-owned structured runtime logging */
|
||||||
|
enabled: boolean;
|
||||||
|
/** Minimum level written to disk */
|
||||||
|
level: LoggingLevel;
|
||||||
|
/** Rotate current log when it reaches this size in MB */
|
||||||
|
rotate_mb: number;
|
||||||
|
/** Keep archived segments for this many days */
|
||||||
|
retain_days: number;
|
||||||
|
/** Redact sensitive values before persistence */
|
||||||
|
redact: boolean;
|
||||||
|
/** In-memory recent event buffer size for dashboard reads */
|
||||||
|
live_buffer_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_LOGGING_CONFIG: LoggingConfig = {
|
||||||
|
enabled: true,
|
||||||
|
level: 'info',
|
||||||
|
rotate_mb: 10,
|
||||||
|
retain_days: 7,
|
||||||
|
redact: true,
|
||||||
|
live_buffer_size: 250,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User preferences.
|
* User preferences.
|
||||||
*/
|
*/
|
||||||
@@ -812,6 +842,8 @@ export interface UnifiedConfig {
|
|||||||
profiles: Record<string, ProfileConfig>;
|
profiles: Record<string, ProfileConfig>;
|
||||||
/** CLIProxy configuration */
|
/** CLIProxy configuration */
|
||||||
cliproxy: CLIProxyConfig;
|
cliproxy: CLIProxyConfig;
|
||||||
|
/** CCS-owned structured logging configuration */
|
||||||
|
logging?: LoggingConfig;
|
||||||
/** User preferences */
|
/** User preferences */
|
||||||
preferences: PreferencesConfig;
|
preferences: PreferencesConfig;
|
||||||
/** WebSearch configuration */
|
/** WebSearch configuration */
|
||||||
@@ -912,6 +944,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
|
|||||||
strategy: 'round-robin',
|
strategy: 'round-robin',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
logging: { ...DEFAULT_LOGGING_CONFIG },
|
||||||
preferences: {
|
preferences: {
|
||||||
theme: 'system',
|
theme: 'system',
|
||||||
telemetry: false,
|
telemetry: false,
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
import { ExitCode, EXIT_CODE_DESCRIPTIONS } from './exit-codes';
|
import { ExitCode, EXIT_CODE_DESCRIPTIONS } from './exit-codes';
|
||||||
import { isCCSError } from './error-types';
|
import { isCCSError } from './error-types';
|
||||||
import { runCleanup } from './cleanup-registry';
|
import { runCleanup } from './cleanup-registry';
|
||||||
|
import { createLogger } from '../services/logging';
|
||||||
|
|
||||||
|
const logger = createLogger('cli:error-handler');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Debug mode flag - set via CCS_DEBUG environment variable
|
* Debug mode flag - set via CCS_DEBUG environment variable
|
||||||
@@ -91,6 +94,10 @@ export function handleError(error: unknown): never {
|
|||||||
|
|
||||||
const code = getExitCode(error);
|
const code = getExitCode(error);
|
||||||
const message = formatErrorMessage(error);
|
const message = formatErrorMessage(error);
|
||||||
|
logger.error('command.unhandled_error', 'Unhandled CLI error', {
|
||||||
|
exitCode: code,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
|
||||||
// Output error message to stderr
|
// Output error message to stderr
|
||||||
console.error(message);
|
console.error(message);
|
||||||
@@ -112,6 +119,10 @@ export function handleError(error: unknown): never {
|
|||||||
*/
|
*/
|
||||||
export function exitWithError(message: string, code: ExitCode = ExitCode.GENERAL_ERROR): never {
|
export function exitWithError(message: string, code: ExitCode = ExitCode.GENERAL_ERROR): never {
|
||||||
runCleanup();
|
runCleanup();
|
||||||
|
logger.error('command.exit_error', 'CLI exited with error', {
|
||||||
|
exitCode: code,
|
||||||
|
message,
|
||||||
|
});
|
||||||
console.error(`[X] ${message}`);
|
console.error(`[X] ${message}`);
|
||||||
|
|
||||||
if (isDebugMode()) {
|
if (isDebugMode()) {
|
||||||
@@ -131,6 +142,9 @@ export function exitWithError(message: string, code: ExitCode = ExitCode.GENERAL
|
|||||||
*/
|
*/
|
||||||
export function exitWithSuccess(message?: string): never {
|
export function exitWithSuccess(message?: string): never {
|
||||||
runCleanup();
|
runCleanup();
|
||||||
|
logger.info('command.exit_success', 'CLI exited successfully', {
|
||||||
|
message: message || null,
|
||||||
|
});
|
||||||
if (message) {
|
if (message) {
|
||||||
console.log(`[OK] ${message}`);
|
console.log(`[OK] ${message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { DeltaAccumulator } from './delta-accumulator';
|
import { DeltaAccumulator } from './delta-accumulator';
|
||||||
import { getCcsDir } from '../utils/config-manager';
|
import { getCcsDir } from '../utils/config-manager';
|
||||||
|
import { createLogger } from '../services/logging';
|
||||||
import {
|
import {
|
||||||
RequestTransformer,
|
RequestTransformer,
|
||||||
StreamParser,
|
StreamParser,
|
||||||
@@ -36,6 +37,7 @@ export class GlmtTransformer {
|
|||||||
private verbose: boolean;
|
private verbose: boolean;
|
||||||
private debugLog: boolean;
|
private debugLog: boolean;
|
||||||
debugLogDir: string;
|
debugLogDir: string;
|
||||||
|
private readonly logger = createLogger('glmt:transformer');
|
||||||
|
|
||||||
private requestTransformer: RequestTransformer;
|
private requestTransformer: RequestTransformer;
|
||||||
private streamParser: StreamParser;
|
private streamParser: StreamParser;
|
||||||
@@ -125,6 +127,9 @@ export class GlmtTransformer {
|
|||||||
return anthropicResponse;
|
return anthropicResponse;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
|
this.logger.error('response.transform_failed', 'GLMT response transformation failed', {
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
console.error('[glmt-transformer] Response transformation error:', err);
|
console.error('[glmt-transformer] Response transformation error:', err);
|
||||||
return {
|
return {
|
||||||
id: 'msg_error_' + Date.now(),
|
id: 'msg_error_' + Date.now(),
|
||||||
@@ -175,12 +180,16 @@ export class GlmtTransformer {
|
|||||||
const redacted = this.redactSensitiveData(data);
|
const redacted = this.redactSensitiveData(data);
|
||||||
fs.writeFileSync(filepath, JSON.stringify(redacted, null, 2) + '\n', 'utf8');
|
fs.writeFileSync(filepath, JSON.stringify(redacted, null, 2) + '\n', 'utf8');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
this.logger.warn('debug-log.write_failed', 'GLMT debug log write failed', {
|
||||||
|
message: (error as Error).message,
|
||||||
|
});
|
||||||
console.error(`[glmt-transformer] Debug log error: ${(error as Error).message}`);
|
console.error(`[glmt-transformer] Debug log error: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private log(message: string): void {
|
private log(message: string): void {
|
||||||
if (this.verbose) {
|
if (this.verbose) {
|
||||||
|
this.logger.debug('transformer.verbose', message);
|
||||||
console.error(`[glmt-transformer] [${new Date().toTimeString().split(' ')[0]}] ${message}`);
|
console.error(`[glmt-transformer] [${new Date().toTimeString().split(' ')[0]}] ${message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export { createLogger } from './logger';
|
||||||
|
export { getResolvedLoggingConfig, invalidateLoggingConfigCache } from './log-config';
|
||||||
|
export { readLogEntries, readLogSourceSummaries, normalizeLogQueryLevel } from './log-reader';
|
||||||
|
export { pruneExpiredLogArchives } from './log-storage';
|
||||||
|
export {
|
||||||
|
ensureLoggingDirectories,
|
||||||
|
getCurrentLogPath,
|
||||||
|
getLegacyCliproxyLogsDir,
|
||||||
|
getLogArchiveDir,
|
||||||
|
getNativeLogsDir,
|
||||||
|
isPathInsideDirectory,
|
||||||
|
} from './log-paths';
|
||||||
|
export type { LogEntry, LogSourceSummary, LoggingLevel, ReadLogEntriesOptions } from './log-types';
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { LogEntry } from './log-types';
|
||||||
|
|
||||||
|
let recentEntries: LogEntry[] = [];
|
||||||
|
|
||||||
|
export function pushRecentLogEntry(entry: LogEntry, maxEntries: number): void {
|
||||||
|
recentEntries.push(entry);
|
||||||
|
if (recentEntries.length > maxEntries) {
|
||||||
|
recentEntries = recentEntries.slice(recentEntries.length - maxEntries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecentLogEntries(): LogEntry[] {
|
||||||
|
return [...recentEntries];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearRecentLogEntries(): void {
|
||||||
|
recentEntries = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import { DEFAULT_LOGGING_CONFIG } from '../../config/unified-config-types';
|
||||||
|
import {
|
||||||
|
getConfigYamlPath,
|
||||||
|
getLoggingConfig as getUnifiedLoggingConfig,
|
||||||
|
} from '../../config/unified-config-loader';
|
||||||
|
import type { LoggingConfig } from './log-types';
|
||||||
|
|
||||||
|
const CACHE_RECHECK_MS = 1000;
|
||||||
|
let cachedConfig: LoggingConfig = { ...DEFAULT_LOGGING_CONFIG };
|
||||||
|
let cachedMtimeMs: number | null = null;
|
||||||
|
let lastCheckedAt = 0;
|
||||||
|
|
||||||
|
export function invalidateLoggingConfigCache(): void {
|
||||||
|
cachedConfig = { ...DEFAULT_LOGGING_CONFIG };
|
||||||
|
cachedMtimeMs = null;
|
||||||
|
lastCheckedAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getResolvedLoggingConfig(): LoggingConfig {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastCheckedAt < CACHE_RECHECK_MS) {
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configPath = getConfigYamlPath();
|
||||||
|
const nextMtimeMs = fs.existsSync(configPath) ? fs.statSync(configPath).mtimeMs : null;
|
||||||
|
if (nextMtimeMs === cachedMtimeMs) {
|
||||||
|
lastCheckedAt = now;
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedConfig = {
|
||||||
|
...DEFAULT_LOGGING_CONFIG,
|
||||||
|
...getUnifiedLoggingConfig(),
|
||||||
|
};
|
||||||
|
cachedMtimeMs = nextMtimeMs;
|
||||||
|
lastCheckedAt = now;
|
||||||
|
return cachedConfig;
|
||||||
|
} catch {
|
||||||
|
cachedConfig = { ...DEFAULT_LOGGING_CONFIG };
|
||||||
|
cachedMtimeMs = null;
|
||||||
|
lastCheckedAt = now;
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { getCcsDir } from '../../utils/config-manager';
|
||||||
|
|
||||||
|
const LOGS_DIR = 'logs';
|
||||||
|
const ARCHIVE_DIR = 'archive';
|
||||||
|
const CURRENT_LOG_FILE = 'current.jsonl';
|
||||||
|
|
||||||
|
export function getNativeLogsDir(): string {
|
||||||
|
return path.join(getCcsDir(), LOGS_DIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentLogPath(): string {
|
||||||
|
return path.join(getNativeLogsDir(), CURRENT_LOG_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLogArchiveDir(): string {
|
||||||
|
return path.join(getNativeLogsDir(), ARCHIVE_DIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLegacyCliproxyLogsDir(): string {
|
||||||
|
return path.join(getCcsDir(), 'cliproxy', 'logs');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureLoggingDirectories(): void {
|
||||||
|
fs.mkdirSync(getNativeLogsDir(), { recursive: true });
|
||||||
|
fs.mkdirSync(getLogArchiveDir(), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPathInsideDirectory(candidatePath: string, rootDir: string): boolean {
|
||||||
|
const resolvedCandidate = path.resolve(candidatePath);
|
||||||
|
const resolvedRoot = path.resolve(rootDir);
|
||||||
|
const relative = path.relative(resolvedRoot, resolvedCandidate);
|
||||||
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildArchiveLogPath(timestamp: Date = new Date()): string {
|
||||||
|
const compact = timestamp.toISOString().replace(/[:.]/g, '-');
|
||||||
|
return path.join(getLogArchiveDir(), `ccs-${compact}.jsonl.gz`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import { getRecentLogEntries } from './log-buffer';
|
||||||
|
import { getCurrentLogPath } from './log-paths';
|
||||||
|
import {
|
||||||
|
isLoggingLevel,
|
||||||
|
type LogEntry,
|
||||||
|
type LogSourceSummary,
|
||||||
|
type ReadLogEntriesOptions,
|
||||||
|
} from './log-types';
|
||||||
|
|
||||||
|
function parseLogLine(line: string): LogEntry | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(line) as LogEntry;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCurrentFileEntries(): LogEntry[] {
|
||||||
|
const currentLogPath = getCurrentLogPath();
|
||||||
|
if (!fs.existsSync(currentLogPath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs
|
||||||
|
.readFileSync(currentLogPath, 'utf8')
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(parseLogLine)
|
||||||
|
.filter((entry): entry is LogEntry => entry !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeEntries(entries: LogEntry[]): LogEntry[] {
|
||||||
|
const seen = new Map<string, LogEntry>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
seen.set(entry.id, entry);
|
||||||
|
}
|
||||||
|
return [...seen.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLogEntries(options: ReadLogEntriesOptions = {}): LogEntry[] {
|
||||||
|
const entries = dedupeEntries([...readCurrentFileEntries(), ...getRecentLogEntries()])
|
||||||
|
.filter((entry) => (options.source ? entry.source === options.source : true))
|
||||||
|
.filter((entry) => (options.level ? entry.level === options.level : true))
|
||||||
|
.filter((entry) => {
|
||||||
|
if (!options.search) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const search = options.search.toLowerCase();
|
||||||
|
return (
|
||||||
|
entry.message.toLowerCase().includes(search) ||
|
||||||
|
entry.event.toLowerCase().includes(search) ||
|
||||||
|
entry.source.toLowerCase().includes(search) ||
|
||||||
|
String(entry.processId).toLowerCase().includes(search) ||
|
||||||
|
entry.runId.toLowerCase().includes(search) ||
|
||||||
|
JSON.stringify(entry.context || {})
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(search)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
||||||
|
|
||||||
|
return entries.slice(0, options.limit ?? 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLogSourceSummaries(): LogSourceSummary[] {
|
||||||
|
const summaryMap = new Map<string, LogSourceSummary>();
|
||||||
|
for (const entry of readLogEntries({ limit: 500 })) {
|
||||||
|
const current = summaryMap.get(entry.source) ?? {
|
||||||
|
source: entry.source,
|
||||||
|
label: entry.source,
|
||||||
|
kind: 'native' as const,
|
||||||
|
count: 0,
|
||||||
|
lastTimestamp: null,
|
||||||
|
};
|
||||||
|
current.count += 1;
|
||||||
|
current.lastTimestamp = current.lastTimestamp ?? entry.timestamp;
|
||||||
|
summaryMap.set(entry.source, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...summaryMap.values()].sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLogQueryLevel(level: string | undefined) {
|
||||||
|
return isLoggingLevel(level) ? level : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
const SENSITIVE_KEY_PATTERN =
|
||||||
|
/^(authorization|cookie|set-cookie|password|password_hash|secret|token|api[_-]?key|management[_-]?key)$/i;
|
||||||
|
const MAX_STRING_LENGTH = 2000;
|
||||||
|
const MAX_DEPTH = 5;
|
||||||
|
|
||||||
|
function truncateString(value: string): string {
|
||||||
|
if (value.length <= MAX_STRING_LENGTH) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return `${value.slice(0, MAX_STRING_LENGTH)}...[truncated]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeValue(value: unknown, depth: number): unknown {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth >= MAX_DEPTH) {
|
||||||
|
return '[max-depth]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return truncateString(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Error) {
|
||||||
|
return {
|
||||||
|
name: value.name,
|
||||||
|
message: truncateString(value.message),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => sanitizeValue(item, depth + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const sanitized: Record<string, unknown> = {};
|
||||||
|
for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
sanitized[key] = SENSITIVE_KEY_PATTERN.test(key)
|
||||||
|
? '[redacted]'
|
||||||
|
: sanitizeValue(nestedValue, depth + 1);
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactContext(
|
||||||
|
context: Record<string, unknown> | undefined
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!context) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitizeValue(context, 0) as Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as zlib from 'zlib';
|
||||||
|
import { getResolvedLoggingConfig } from './log-config';
|
||||||
|
import {
|
||||||
|
ensureLoggingDirectories,
|
||||||
|
getCurrentLogPath,
|
||||||
|
buildArchiveLogPath,
|
||||||
|
getLogArchiveDir,
|
||||||
|
} from './log-paths';
|
||||||
|
import { pushRecentLogEntry } from './log-buffer';
|
||||||
|
import { shouldWriteLogLevel, type LogEntry } from './log-types';
|
||||||
|
|
||||||
|
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const PRUNE_INTERVAL_MS = 60 * 1000;
|
||||||
|
let lastPruneAt = 0;
|
||||||
|
|
||||||
|
function getRotateBytes(rotateMb: number): number {
|
||||||
|
return Math.max(1, rotateMb) * 1024 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateCurrentLogIfNeeded(): void {
|
||||||
|
const config = getResolvedLoggingConfig();
|
||||||
|
const currentLogPath = getCurrentLogPath();
|
||||||
|
|
||||||
|
if (!fs.existsSync(currentLogPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = fs.statSync(currentLogPath);
|
||||||
|
const ageMs = Date.now() - stats.mtimeMs;
|
||||||
|
const exceedsSize = stats.size >= getRotateBytes(config.rotate_mb);
|
||||||
|
const exceedsAge = ageMs >= ONE_DAY_MS;
|
||||||
|
if (!exceedsSize && !exceedsAge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentContent = fs.readFileSync(currentLogPath, 'utf8');
|
||||||
|
if (!currentContent.trim()) {
|
||||||
|
fs.truncateSync(currentLogPath, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const archivePath = buildArchiveLogPath(new Date(stats.mtimeMs || Date.now()));
|
||||||
|
fs.writeFileSync(archivePath, zlib.gzipSync(currentContent), { mode: 0o600 });
|
||||||
|
fs.truncateSync(currentLogPath, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneExpiredLogArchives(): void {
|
||||||
|
const config = getResolvedLoggingConfig();
|
||||||
|
const archiveDir = getLogArchiveDir();
|
||||||
|
if (!fs.existsSync(archiveDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cutoffMs = Date.now() - config.retain_days * ONE_DAY_MS;
|
||||||
|
for (const entry of fs.readdirSync(archiveDir)) {
|
||||||
|
const archivePath = path.join(archiveDir, entry);
|
||||||
|
try {
|
||||||
|
const stats = fs.lstatSync(archivePath);
|
||||||
|
if (!stats.isFile() || stats.isSymbolicLink()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (stats.mtimeMs < cutoffMs) {
|
||||||
|
fs.unlinkSync(archivePath);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendStructuredLogEntry(entry: LogEntry): void {
|
||||||
|
const config = getResolvedLoggingConfig();
|
||||||
|
if (!config.enabled || !shouldWriteLogLevel(entry.level, config.level)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureLoggingDirectories();
|
||||||
|
rotateCurrentLogIfNeeded();
|
||||||
|
fs.appendFileSync(getCurrentLogPath(), `${JSON.stringify(entry)}\n`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
pushRecentLogEntry(entry, config.live_buffer_size);
|
||||||
|
if (Date.now() - lastPruneAt >= PRUNE_INTERVAL_MS) {
|
||||||
|
pruneExpiredLogArchives();
|
||||||
|
lastPruneAt = Date.now();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Logging must never break runtime behavior.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { LoggingConfig, LoggingLevel } from '../../config/unified-config-types';
|
||||||
|
|
||||||
|
export type { LoggingConfig, LoggingLevel };
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
level: LoggingLevel;
|
||||||
|
source: string;
|
||||||
|
event: string;
|
||||||
|
message: string;
|
||||||
|
processId: number;
|
||||||
|
runId: string;
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogSourceSummary {
|
||||||
|
source: string;
|
||||||
|
label: string;
|
||||||
|
kind: 'native' | 'legacy';
|
||||||
|
count: number;
|
||||||
|
lastTimestamp: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReadLogEntriesOptions {
|
||||||
|
source?: string;
|
||||||
|
level?: LoggingLevel;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LOG_LEVELS: readonly LoggingLevel[] = ['error', 'warn', 'info', 'debug'];
|
||||||
|
|
||||||
|
const LOG_LEVEL_PRIORITY: Record<LoggingLevel, number> = {
|
||||||
|
error: 0,
|
||||||
|
warn: 1,
|
||||||
|
info: 2,
|
||||||
|
debug: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function shouldWriteLogLevel(level: LoggingLevel, configuredLevel: LoggingLevel): boolean {
|
||||||
|
return LOG_LEVEL_PRIORITY[level] <= LOG_LEVEL_PRIORITY[configuredLevel];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLoggingLevel(value: string | undefined): value is LoggingLevel {
|
||||||
|
return typeof value === 'string' && LOG_LEVELS.includes(value as LoggingLevel);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { getResolvedLoggingConfig } from './log-config';
|
||||||
|
import { redactContext } from './log-redaction';
|
||||||
|
import { appendStructuredLogEntry } from './log-storage';
|
||||||
|
import type { LogEntry, LoggingLevel } from './log-types';
|
||||||
|
|
||||||
|
const processRunId = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
|
||||||
|
function createEntry(
|
||||||
|
source: string,
|
||||||
|
level: LoggingLevel,
|
||||||
|
event: string,
|
||||||
|
message: string,
|
||||||
|
context: Record<string, unknown>
|
||||||
|
): LogEntry {
|
||||||
|
const config = getResolvedLoggingConfig();
|
||||||
|
return {
|
||||||
|
id: randomUUID(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
level,
|
||||||
|
source,
|
||||||
|
event,
|
||||||
|
message,
|
||||||
|
processId: process.pid,
|
||||||
|
runId: processRunId,
|
||||||
|
context: config.redact ? redactContext(context) : context,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Logger {
|
||||||
|
child(context: Record<string, unknown>): Logger;
|
||||||
|
debug(event: string, message: string, context?: Record<string, unknown>): void;
|
||||||
|
info(event: string, message: string, context?: Record<string, unknown>): void;
|
||||||
|
warn(event: string, message: string, context?: Record<string, unknown>): void;
|
||||||
|
error(event: string, message: string, context?: Record<string, unknown>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLogger(source: string, baseContext: Record<string, unknown> = {}): Logger {
|
||||||
|
const write = (
|
||||||
|
level: LoggingLevel,
|
||||||
|
event: string,
|
||||||
|
message: string,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
) => {
|
||||||
|
appendStructuredLogEntry(
|
||||||
|
createEntry(source, level, event, message, { ...baseContext, ...(context || {}) })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
child(context: Record<string, unknown>) {
|
||||||
|
return createLogger(source, { ...baseContext, ...context });
|
||||||
|
},
|
||||||
|
debug(event, message, context) {
|
||||||
|
write('debug', event, message, context);
|
||||||
|
},
|
||||||
|
info(event, message, context) {
|
||||||
|
write('info', event, message, context);
|
||||||
|
},
|
||||||
|
warn(event, message, context) {
|
||||||
|
write('warn', event, message, context);
|
||||||
|
},
|
||||||
|
error(event, message, context) {
|
||||||
|
write('error', event, message, context);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import * as fs from 'fs';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { getCcsDir } from '../config-manager';
|
import { getCcsDir } from '../config-manager';
|
||||||
|
import { createLogger } from '../../services/logging';
|
||||||
|
|
||||||
const TRACE_FILE_NAME = 'websearch-trace.jsonl';
|
const TRACE_FILE_NAME = 'websearch-trace.jsonl';
|
||||||
const NATIVE_WEBSEARCH_TOOL = 'WebSearch';
|
const NATIVE_WEBSEARCH_TOOL = 'WebSearch';
|
||||||
@@ -17,6 +18,7 @@ const DISALLOWED_TOOLS_FLAG = '--disallowedTools';
|
|||||||
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
|
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
|
||||||
const THIRD_PARTY_WEBSEARCH_STEERING_PROMPT =
|
const THIRD_PARTY_WEBSEARCH_STEERING_PROMPT =
|
||||||
'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.';
|
'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.';
|
||||||
|
const logger = createLogger('websearch');
|
||||||
|
|
||||||
function parseToolValue(rawValue: string): string[] {
|
function parseToolValue(rawValue: string): string[] {
|
||||||
return rawValue
|
return rawValue
|
||||||
@@ -121,6 +123,11 @@ export function appendWebSearchTrace(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
logger.info('trace.append', 'WebSearch trace event recorded', {
|
||||||
|
event,
|
||||||
|
launchId: env.CCS_WEBSEARCH_TRACE_LAUNCH_ID || null,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
const traceFilePath = getTraceFilePath(env);
|
const traceFilePath = getTraceFilePath(env);
|
||||||
fs.mkdirSync(path.dirname(traceFilePath), { recursive: true });
|
fs.mkdirSync(path.dirname(traceFilePath), { recursive: true });
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import path from 'path';
|
|||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
import { setupWebSocket } from './websocket';
|
import { setupWebSocket } from './websocket';
|
||||||
import { createSessionMiddleware, authMiddleware } from './middleware/auth-middleware';
|
import { createSessionMiddleware, authMiddleware } from './middleware/auth-middleware';
|
||||||
|
import { requestLoggingMiddleware } from './middleware/request-logging-middleware';
|
||||||
import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync';
|
import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync';
|
||||||
import { shutdownUsageAggregator } from './usage/aggregator';
|
import { shutdownUsageAggregator } from './usage/aggregator';
|
||||||
|
import { createLogger } from '../services/logging';
|
||||||
|
|
||||||
export interface ServerOptions {
|
export interface ServerOptions {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -28,6 +30,8 @@ export interface ServerInstance {
|
|||||||
cleanup: () => void;
|
cleanup: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const logger = createLogger('web-server');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start Express server with WebSocket support
|
* Start Express server with WebSocket support
|
||||||
*/
|
*/
|
||||||
@@ -57,6 +61,7 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
|
|||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
app.use(requestLoggingMiddleware);
|
||||||
|
|
||||||
// Session middleware (for dashboard auth)
|
// Session middleware (for dashboard auth)
|
||||||
app.use(createSessionMiddleware());
|
app.use(createSessionMiddleware());
|
||||||
@@ -124,6 +129,12 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
|
|||||||
// Start listening
|
// Start listening
|
||||||
return new Promise<ServerInstance>((resolve, reject) => {
|
return new Promise<ServerInstance>((resolve, reject) => {
|
||||||
const onError = (error: NodeJS.ErrnoException) => {
|
const onError = (error: NodeJS.ErrnoException) => {
|
||||||
|
logger.error('server.listen_failed', 'Dashboard server failed to start', {
|
||||||
|
code: error.code || 'unknown',
|
||||||
|
message: error.message,
|
||||||
|
host: options.host || null,
|
||||||
|
port: options.port,
|
||||||
|
});
|
||||||
cleanup();
|
cleanup();
|
||||||
reject(new Error(formatListenError(error, options)));
|
reject(new Error(formatListenError(error, options)));
|
||||||
};
|
};
|
||||||
@@ -132,6 +143,11 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
|
|||||||
|
|
||||||
const onListening = () => {
|
const onListening = () => {
|
||||||
server.off('error', onError);
|
server.off('error', onError);
|
||||||
|
logger.info('server.listening', 'Dashboard server listening', {
|
||||||
|
host: options.host || '0.0.0.0',
|
||||||
|
port: options.port,
|
||||||
|
dev: Boolean(options.dev),
|
||||||
|
});
|
||||||
// Usage cache loads on-demand when Analytics page is visited
|
// Usage cache loads on-demand when Analytics page is visited
|
||||||
// This keeps server startup instant for users who don't need analytics
|
// This keeps server startup instant for users who don't need analytics
|
||||||
resolve({ server, wss, cleanup });
|
resolve({ server, wss, cleanup });
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
import { createLogger } from '../../services/logging';
|
||||||
|
|
||||||
|
const logger = createLogger('web-server:http');
|
||||||
|
|
||||||
|
export function requestLoggingMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
const requestId = randomUUID();
|
||||||
|
const startTime = Date.now();
|
||||||
|
res.locals.ccsRequestId = requestId;
|
||||||
|
res.setHeader('x-ccs-request-id', requestId);
|
||||||
|
const shouldSkipLogging = req.originalUrl.startsWith('/api/logs');
|
||||||
|
|
||||||
|
res.on('finish', () => {
|
||||||
|
if (shouldSkipLogging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.info('request.completed', 'Dashboard request completed', {
|
||||||
|
requestId,
|
||||||
|
method: req.method,
|
||||||
|
path: req.originalUrl,
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
remoteAddress: req.socket.remoteAddress || null,
|
||||||
|
userAgent: req.headers['user-agent'] || null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ import authRoutes from './auth-routes';
|
|||||||
import persistRoutes from './persist-routes';
|
import persistRoutes from './persist-routes';
|
||||||
import catalogRoutes from './catalog-routes';
|
import catalogRoutes from './catalog-routes';
|
||||||
import claudeExtensionRoutes from './claude-extension-routes';
|
import claudeExtensionRoutes from './claude-extension-routes';
|
||||||
|
import logsRoutes from './logs-routes';
|
||||||
|
|
||||||
// Create the main API router
|
// Create the main API router
|
||||||
export const apiRoutes = Router();
|
export const apiRoutes = Router();
|
||||||
@@ -115,3 +116,4 @@ apiRoutes.use('/cliproxy-server', cliproxyServerRoutes);
|
|||||||
|
|
||||||
// ==================== Misc (File API, Global Env) ====================
|
// ==================== Misc (File API, Global Env) ====================
|
||||||
apiRoutes.use('/', miscRoutes);
|
apiRoutes.use('/', miscRoutes);
|
||||||
|
apiRoutes.use('/logs', logsRoutes);
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { Router, type Request, type Response } from 'express';
|
||||||
|
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||||
|
import { isLoggingLevel } from '../../services/logging/log-types';
|
||||||
|
import {
|
||||||
|
getDashboardLoggingConfig,
|
||||||
|
listDashboardLogEntries,
|
||||||
|
listDashboardLogSources,
|
||||||
|
updateDashboardLoggingConfig,
|
||||||
|
} from '../services/logs-dashboard-service';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const LOGS_LOCAL_ACCESS_ERROR =
|
||||||
|
'Logs endpoints require localhost access when dashboard auth is disabled.';
|
||||||
|
|
||||||
|
router.use((req: Request, res: Response, next) => {
|
||||||
|
if (requireLocalAccessWhenAuthDisabled(req, res, LOGS_LOCAL_ACCESS_ERROR)) {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/config', (_req: Request, res: Response) => {
|
||||||
|
res.json({ logging: getDashboardLoggingConfig() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/config', (req: Request, res: Response) => {
|
||||||
|
const updates = req.body as Record<string, unknown>;
|
||||||
|
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
||||||
|
res.status(400).json({ error: 'Invalid request body. Must be an object.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
enabled,
|
||||||
|
level,
|
||||||
|
rotate_mb: rotateMb,
|
||||||
|
retain_days: retainDays,
|
||||||
|
redact,
|
||||||
|
live_buffer_size: liveBufferSize,
|
||||||
|
} = updates;
|
||||||
|
|
||||||
|
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
||||||
|
res.status(400).json({ error: 'enabled must be a boolean' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (level !== undefined && !isLoggingLevel(String(level))) {
|
||||||
|
res.status(400).json({ error: 'level must be one of error, warn, info, debug' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericPairs = [
|
||||||
|
['rotate_mb', rotateMb],
|
||||||
|
['retain_days', retainDays],
|
||||||
|
['live_buffer_size', liveBufferSize],
|
||||||
|
] as const;
|
||||||
|
for (const [field, value] of numericPairs) {
|
||||||
|
if (value !== undefined && (!Number.isInteger(value) || Number(value) < 1)) {
|
||||||
|
res.status(400).json({ error: `${field} must be a positive integer` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redact !== undefined && typeof redact !== 'boolean') {
|
||||||
|
res.status(400).json({ error: 'redact must be a boolean' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
logging: updateDashboardLoggingConfig({
|
||||||
|
enabled: enabled as boolean | undefined,
|
||||||
|
level: level as 'error' | 'warn' | 'info' | 'debug' | undefined,
|
||||||
|
rotate_mb: rotateMb as number | undefined,
|
||||||
|
retain_days: retainDays as number | undefined,
|
||||||
|
redact: redact as boolean | undefined,
|
||||||
|
live_buffer_size: liveBufferSize as number | undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/sources', (_req: Request, res: Response) => {
|
||||||
|
res.json({ sources: listDashboardLogSources() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/entries', (req: Request, res: Response) => {
|
||||||
|
const { source, level, search, limit } = req.query;
|
||||||
|
const parsedLimit =
|
||||||
|
typeof limit === 'string' && Number.isInteger(Number(limit)) ? Number(limit) : undefined;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
entries: listDashboardLogEntries({
|
||||||
|
source: typeof source === 'string' ? source : undefined,
|
||||||
|
level: typeof level === 'string' && isLoggingLevel(level) ? level : undefined,
|
||||||
|
search: typeof search === 'string' ? search : undefined,
|
||||||
|
limit: parsedLimit,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { mutateUnifiedConfig } from '../../config/unified-config-loader';
|
||||||
|
import {
|
||||||
|
getResolvedLoggingConfig,
|
||||||
|
invalidateLoggingConfigCache,
|
||||||
|
readLogEntries,
|
||||||
|
readLogSourceSummaries,
|
||||||
|
} from '../../services/logging';
|
||||||
|
import type { LoggingConfig } from '../../config/unified-config-types';
|
||||||
|
import type { ReadLogEntriesOptions } from '../../services/logging';
|
||||||
|
|
||||||
|
export function getDashboardLoggingConfig(): LoggingConfig {
|
||||||
|
return getResolvedLoggingConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateDashboardLoggingConfig(updates: Partial<LoggingConfig>): LoggingConfig {
|
||||||
|
const updated = mutateUnifiedConfig((config) => {
|
||||||
|
config.logging = {
|
||||||
|
...getResolvedLoggingConfig(),
|
||||||
|
...config.logging,
|
||||||
|
...updates,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
invalidateLoggingConfigCache();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...getResolvedLoggingConfig(),
|
||||||
|
...updated.logging,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDashboardLogSources() {
|
||||||
|
return readLogSourceSummaries();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDashboardLogEntries(options: ReadLogEntriesOptions = {}) {
|
||||||
|
return readLogEntries(options);
|
||||||
|
}
|
||||||
+24
-14
@@ -7,12 +7,14 @@
|
|||||||
|
|
||||||
import { WebSocketServer, WebSocket } from 'ws';
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
import { createFileWatcher, FileChangeEvent } from './file-watcher';
|
import { createFileWatcher, FileChangeEvent } from './file-watcher';
|
||||||
import { info, warn } from '../utils/ui';
|
|
||||||
import {
|
import {
|
||||||
projectSelectionEvents,
|
projectSelectionEvents,
|
||||||
type ProjectSelectionPrompt,
|
type ProjectSelectionPrompt,
|
||||||
} from '../cliproxy/project-selection-handler';
|
} from '../cliproxy/project-selection-handler';
|
||||||
import { deviceCodeEvents, type DeviceCodePrompt } from '../cliproxy/device-code-handler';
|
import { deviceCodeEvents, type DeviceCodePrompt } from '../cliproxy/device-code-handler';
|
||||||
|
import { createLogger } from '../services/logging';
|
||||||
|
|
||||||
|
const logger = createLogger('web-server:websocket');
|
||||||
|
|
||||||
export interface WSMessage {
|
export interface WSMessage {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -36,7 +38,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
|
|||||||
// Handle new connections
|
// Handle new connections
|
||||||
wss.on('connection', (ws) => {
|
wss.on('connection', (ws) => {
|
||||||
clients.add(ws);
|
clients.add(ws);
|
||||||
console.log(info(`[WS] Client connected (${clients.size} total)`));
|
logger.info('client.connected', 'WebSocket client connected', { clients: clients.size });
|
||||||
|
|
||||||
// Send welcome message
|
// Send welcome message
|
||||||
ws.send(JSON.stringify({ type: 'connected', timestamp: Date.now() }));
|
ws.send(JSON.stringify({ type: 'connected', timestamp: Date.now() }));
|
||||||
@@ -47,18 +49,20 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
|
|||||||
const message = JSON.parse(data.toString());
|
const message = JSON.parse(data.toString());
|
||||||
handleClientMessage(ws, message);
|
handleClientMessage(ws, message);
|
||||||
} catch {
|
} catch {
|
||||||
console.log(warn('[WS] Invalid message format'));
|
logger.warn('message.invalid', 'WebSocket client sent invalid JSON');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle disconnect
|
// Handle disconnect
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
clients.delete(ws);
|
clients.delete(ws);
|
||||||
console.log(info(`[WS] Client disconnected (${clients.size} remaining)`));
|
logger.info('client.disconnected', 'WebSocket client disconnected', {
|
||||||
|
clients: clients.size,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('error', (err) => {
|
ws.on('error', (err) => {
|
||||||
console.log(warn(`[WS] Error: ${err.message}`));
|
logger.warn('client.error', 'WebSocket client error', { message: err.message });
|
||||||
clients.delete(ws);
|
clients.delete(ws);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -73,13 +77,15 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
|
|||||||
// Future: selective subscriptions
|
// Future: selective subscriptions
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.log(warn(`[WS] Unknown message type: ${message.type}`));
|
logger.warn('message.unknown', 'WebSocket client sent unknown message type', {
|
||||||
|
type: String(message.type),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup file watcher
|
// Setup file watcher
|
||||||
const watcher = createFileWatcher((event: FileChangeEvent) => {
|
const watcher = createFileWatcher((event: FileChangeEvent) => {
|
||||||
console.log(info(`[FS] ${event.type}: ${event.path}`));
|
logger.debug('file.changed', 'Dashboard file watcher detected a change', { ...event });
|
||||||
broadcast({
|
broadcast({
|
||||||
type: event.type,
|
type: event.type,
|
||||||
path: event.path,
|
path: event.path,
|
||||||
@@ -89,7 +95,9 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
|
|||||||
|
|
||||||
// Listen for project selection events and broadcast to clients
|
// Listen for project selection events and broadcast to clients
|
||||||
const handleProjectSelectionRequired = (prompt: ProjectSelectionPrompt): void => {
|
const handleProjectSelectionRequired = (prompt: ProjectSelectionPrompt): void => {
|
||||||
console.log(info(`[WS] Broadcasting project selection prompt (session: ${prompt.sessionId})`));
|
logger.info('project-selection.required', 'Broadcasting project selection prompt', {
|
||||||
|
sessionId: prompt.sessionId,
|
||||||
|
});
|
||||||
broadcast({
|
broadcast({
|
||||||
type: 'projectSelectionRequired',
|
type: 'projectSelectionRequired',
|
||||||
...prompt,
|
...prompt,
|
||||||
@@ -98,7 +106,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleProjectSelectionTimeout = (sessionId: string): void => {
|
const handleProjectSelectionTimeout = (sessionId: string): void => {
|
||||||
console.log(info(`[WS] Project selection timed out (session: ${sessionId})`));
|
logger.info('project-selection.timeout', 'Project selection prompt timed out', { sessionId });
|
||||||
broadcast({
|
broadcast({
|
||||||
type: 'projectSelectionTimeout',
|
type: 'projectSelectionTimeout',
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -110,7 +118,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
selectedId: string;
|
selectedId: string;
|
||||||
}): void => {
|
}): void => {
|
||||||
console.log(info(`[WS] Project selection submitted (session: ${response.sessionId})`));
|
logger.info('project-selection.submitted', 'Project selection submitted', response);
|
||||||
broadcast({
|
broadcast({
|
||||||
type: 'projectSelectionSubmitted',
|
type: 'projectSelectionSubmitted',
|
||||||
...response,
|
...response,
|
||||||
@@ -120,7 +128,9 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
|
|||||||
|
|
||||||
// Listen for device code events and broadcast to clients
|
// Listen for device code events and broadcast to clients
|
||||||
const handleDeviceCodeReceived = (prompt: DeviceCodePrompt): void => {
|
const handleDeviceCodeReceived = (prompt: DeviceCodePrompt): void => {
|
||||||
console.log(info(`[WS] Broadcasting device code (session: ${prompt.sessionId})`));
|
logger.info('device-code.received', 'Broadcasting device code prompt', {
|
||||||
|
sessionId: prompt.sessionId,
|
||||||
|
});
|
||||||
broadcast({
|
broadcast({
|
||||||
type: 'deviceCodeReceived',
|
type: 'deviceCodeReceived',
|
||||||
...prompt,
|
...prompt,
|
||||||
@@ -129,7 +139,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeviceCodeCompleted = (sessionId: string): void => {
|
const handleDeviceCodeCompleted = (sessionId: string): void => {
|
||||||
console.log(info(`[WS] Device code auth completed (session: ${sessionId})`));
|
logger.info('device-code.completed', 'Device code auth completed', { sessionId });
|
||||||
broadcast({
|
broadcast({
|
||||||
type: 'deviceCodeCompleted',
|
type: 'deviceCodeCompleted',
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -138,7 +148,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeviceCodeFailed = (data: { sessionId: string; error?: string }): void => {
|
const handleDeviceCodeFailed = (data: { sessionId: string; error?: string }): void => {
|
||||||
console.log(info(`[WS] Device code auth failed (session: ${data.sessionId})`));
|
logger.warn('device-code.failed', 'Device code auth failed', data);
|
||||||
broadcast({
|
broadcast({
|
||||||
type: 'deviceCodeFailed',
|
type: 'deviceCodeFailed',
|
||||||
...data,
|
...data,
|
||||||
@@ -147,7 +157,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeviceCodeExpired = (sessionId: string): void => {
|
const handleDeviceCodeExpired = (sessionId: string): void => {
|
||||||
console.log(info(`[WS] Device code expired (session: ${sessionId})`));
|
logger.info('device-code.expired', 'Device code expired', { sessionId });
|
||||||
broadcast({
|
broadcast({
|
||||||
type: 'deviceCodeExpired',
|
type: 'deviceCodeExpired',
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
getCurrentLogPath,
|
||||||
|
getLogArchiveDir,
|
||||||
|
getNativeLogsDir,
|
||||||
|
isPathInsideDirectory,
|
||||||
|
} from '../../../../src/services/logging';
|
||||||
|
|
||||||
|
describe('logging path helpers', () => {
|
||||||
|
let tempHome = '';
|
||||||
|
let originalCcsHome: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalCcsHome = process.env.CCS_HOME;
|
||||||
|
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-log-paths-'));
|
||||||
|
process.env.CCS_HOME = tempHome;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalCcsHome !== undefined) {
|
||||||
|
process.env.CCS_HOME = originalCcsHome;
|
||||||
|
} else {
|
||||||
|
delete process.env.CCS_HOME;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||||
|
tempHome = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves native log paths inside the scoped CCS directory', () => {
|
||||||
|
expect(getNativeLogsDir()).toBe(path.join(tempHome, '.ccs', 'logs'));
|
||||||
|
expect(getCurrentLogPath()).toBe(path.join(tempHome, '.ccs', 'logs', 'current.jsonl'));
|
||||||
|
expect(getLogArchiveDir()).toBe(path.join(tempHome, '.ccs', 'logs', 'archive'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects path escapes outside the CCS log root', () => {
|
||||||
|
const logsDir = getNativeLogsDir();
|
||||||
|
expect(isPathInsideDirectory(path.join(logsDir, 'archive', 'entry.gz'), logsDir)).toBe(true);
|
||||||
|
expect(isPathInsideDirectory(path.join(logsDir, '..', '..', 'etc', 'passwd'), logsDir)).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||||||
|
import express from 'express';
|
||||||
|
import type { Server } from 'http';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import logsRoutes from '../../../src/web-server/routes/logs-routes';
|
||||||
|
import { createLogger } from '../../../src/services/logging';
|
||||||
|
import { clearRecentLogEntries } from '../../../src/services/logging/log-buffer';
|
||||||
|
|
||||||
|
describe('logs routes', () => {
|
||||||
|
let server: Server;
|
||||||
|
let baseUrl = '';
|
||||||
|
let forcedRemoteAddress = '127.0.0.1';
|
||||||
|
let tempHome = '';
|
||||||
|
let originalCcsHome: string | undefined;
|
||||||
|
let originalDashboardAuthEnabled: string | undefined;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
Object.defineProperty(req.socket, 'remoteAddress', {
|
||||||
|
value: forcedRemoteAddress,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use('/api/logs', logsRoutes);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server = app.listen(0, '127.0.0.1');
|
||||||
|
server.once('error', reject);
|
||||||
|
server.once('listening', () => 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}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalCcsHome = process.env.CCS_HOME;
|
||||||
|
originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
|
||||||
|
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-logs-routes-'));
|
||||||
|
process.env.CCS_HOME = tempHome;
|
||||||
|
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false';
|
||||||
|
forcedRemoteAddress = '127.0.0.1';
|
||||||
|
clearRecentLogEntries();
|
||||||
|
|
||||||
|
const logger = createLogger('unit:test');
|
||||||
|
logger.info('seed', 'Seed log entry', { feature: 'logs-routes' });
|
||||||
|
|
||||||
|
const legacyDir = path.join(tempHome, '.ccs', 'cliproxy', 'logs');
|
||||||
|
fs.mkdirSync(legacyDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(legacyDir, 'error-legacy.log'), 'legacy error\n', 'utf8');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalCcsHome !== undefined) {
|
||||||
|
process.env.CCS_HOME = originalCcsHome;
|
||||||
|
} else {
|
||||||
|
delete process.env.CCS_HOME;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalDashboardAuthEnabled !== undefined) {
|
||||||
|
process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled;
|
||||||
|
} else {
|
||||||
|
delete process.env.CCS_DASHBOARD_AUTH_ENABLED;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearRecentLogEntries();
|
||||||
|
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||||
|
tempHome = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns logging config, sources, and entries', async () => {
|
||||||
|
const configResponse = await fetch(`${baseUrl}/api/logs/config`);
|
||||||
|
expect(configResponse.status).toBe(200);
|
||||||
|
const configPayload = (await configResponse.json()) as {
|
||||||
|
logging: { level: string; retain_days: number };
|
||||||
|
};
|
||||||
|
expect(configPayload.logging.level).toBe('info');
|
||||||
|
expect(configPayload.logging.retain_days).toBe(7);
|
||||||
|
|
||||||
|
const sourcesResponse = await fetch(`${baseUrl}/api/logs/sources`);
|
||||||
|
expect(sourcesResponse.status).toBe(200);
|
||||||
|
const sourcesPayload = (await sourcesResponse.json()) as {
|
||||||
|
sources: Array<{ source: string }>;
|
||||||
|
};
|
||||||
|
expect(sourcesPayload.sources.some((source) => source.source === 'unit:test')).toBe(true);
|
||||||
|
|
||||||
|
const entriesResponse = await fetch(`${baseUrl}/api/logs/entries?source=unit:test`);
|
||||||
|
expect(entriesResponse.status).toBe(200);
|
||||||
|
const entriesPayload = (await entriesResponse.json()) as {
|
||||||
|
entries: Array<{ source: string; message: string }>;
|
||||||
|
};
|
||||||
|
expect(entriesPayload.entries[0]?.source).toBe('unit:test');
|
||||||
|
expect(entriesPayload.entries[0]?.message).toBe('Seed log entry');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates logging config through the route', async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/logs/config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
level: 'debug',
|
||||||
|
retain_days: 3,
|
||||||
|
live_buffer_size: 100,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
success: boolean;
|
||||||
|
logging: { level: string; retain_days: number; live_buffer_size: number };
|
||||||
|
};
|
||||||
|
expect(payload.success).toBe(true);
|
||||||
|
expect(payload.logging.level).toBe('debug');
|
||||||
|
expect(payload.logging.retain_days).toBe(3);
|
||||||
|
expect(payload.logging.live_buffer_size).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks remote access when dashboard auth is disabled', async () => {
|
||||||
|
forcedRemoteAddress = '10.10.0.42';
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/api/logs/sources`);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
error: 'Logs endpoints require localhost access when dashboard auth is disabled.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,6 +36,7 @@ const ClaudeExtensionPage = lazy(() =>
|
|||||||
);
|
);
|
||||||
const CodexPage = lazy(() => import('@/pages/codex').then((m) => ({ default: m.CodexPage })));
|
const CodexPage = lazy(() => import('@/pages/codex').then((m) => ({ default: m.CodexPage })));
|
||||||
const DroidPage = lazy(() => import('@/pages/droid').then((m) => ({ default: m.DroidPage })));
|
const DroidPage = lazy(() => import('@/pages/droid').then((m) => ({ default: m.DroidPage })));
|
||||||
|
const LogsPage = lazy(() => import('@/pages/logs').then((m) => ({ default: m.LogsPage })));
|
||||||
const AccountsPage = lazy(() =>
|
const AccountsPage = lazy(() =>
|
||||||
import('@/pages/accounts').then((m) => ({ default: m.AccountsPage }))
|
import('@/pages/accounts').then((m) => ({ default: m.AccountsPage }))
|
||||||
);
|
);
|
||||||
@@ -182,6 +183,14 @@ export default function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/logs"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<LogsPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/shared"
|
path="/shared"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Gauge,
|
Gauge,
|
||||||
|
ScrollText,
|
||||||
Github,
|
Github,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
TerminalSquare,
|
TerminalSquare,
|
||||||
@@ -127,6 +128,7 @@ function buildNavGroups(t: (key: string) => string): SidebarGroupDef[] {
|
|||||||
title: t('nav.system'),
|
title: t('nav.system'),
|
||||||
items: [
|
items: [
|
||||||
{ path: '/health', icon: Activity, label: t('nav.health') },
|
{ path: '/health', icon: Activity, label: t('nav.health') },
|
||||||
|
{ path: '/logs', icon: ScrollText, label: t('nav.logs') },
|
||||||
{ path: '/settings', icon: Settings, label: t('nav.settings') },
|
{ path: '/settings', icon: Settings, label: t('nav.settings') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useDeferredValue, useMemo, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type LogsEntry,
|
||||||
|
type LogsLevel,
|
||||||
|
type UpdateLogsConfigPayload,
|
||||||
|
} from '@/lib/api-client';
|
||||||
|
|
||||||
|
export type LogsLevelFilter = 'all' | LogsLevel;
|
||||||
|
export type LogsSourceFilter = 'all' | string;
|
||||||
|
|
||||||
|
const CONFIG_QUERY_KEY = ['logs', 'config'] as const;
|
||||||
|
const SOURCES_QUERY_KEY = ['logs', 'sources'] as const;
|
||||||
|
const DEFAULT_LIMIT = 150;
|
||||||
|
|
||||||
|
export function useLogsWorkspace() {
|
||||||
|
const [selectedSource, setSelectedSource] = useState<LogsSourceFilter>('all');
|
||||||
|
const [selectedLevel, setSelectedLevel] = useState<LogsLevelFilter>('all');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [limit, setLimit] = useState(DEFAULT_LIMIT);
|
||||||
|
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
|
||||||
|
const deferredSearch = useDeferredValue(search.trim());
|
||||||
|
|
||||||
|
const configQuery = useQuery({
|
||||||
|
queryKey: CONFIG_QUERY_KEY,
|
||||||
|
queryFn: async () => (await api.logs.getConfig()).logging,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourcesQuery = useQuery({
|
||||||
|
queryKey: SOURCES_QUERY_KEY,
|
||||||
|
queryFn: async () => (await api.logs.getSources()).sources,
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const entriesQuery = useQuery({
|
||||||
|
queryKey: ['logs', 'entries', selectedSource, selectedLevel, deferredSearch, limit],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await api.logs.getEntries({
|
||||||
|
source: selectedSource === 'all' ? undefined : selectedSource,
|
||||||
|
level: selectedLevel === 'all' ? undefined : selectedLevel,
|
||||||
|
search: deferredSearch || undefined,
|
||||||
|
limit,
|
||||||
|
})
|
||||||
|
).entries,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeSelectedEntryId = useMemo(() => {
|
||||||
|
const nextEntries = entriesQuery.data ?? [];
|
||||||
|
if (nextEntries.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedEntryId && nextEntries.some((entry) => entry.id === selectedEntryId)) {
|
||||||
|
return selectedEntryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextEntries[0]?.id ?? null;
|
||||||
|
}, [entriesQuery.data, selectedEntryId]);
|
||||||
|
|
||||||
|
const selectedEntry = useMemo(
|
||||||
|
() => (entriesQuery.data ?? []).find((entry) => entry.id === activeSelectedEntryId) ?? null,
|
||||||
|
[activeSelectedEntryId, entriesQuery.data]
|
||||||
|
);
|
||||||
|
|
||||||
|
const latestTimestamp = useMemo(() => {
|
||||||
|
const timestamps = (sourcesQuery.data ?? [])
|
||||||
|
.map((source) => source.lastTimestamp)
|
||||||
|
.filter((value): value is string => Boolean(value));
|
||||||
|
return timestamps.sort((left, right) => right.localeCompare(left))[0] ?? null;
|
||||||
|
}, [sourcesQuery.data]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
configQuery,
|
||||||
|
sourcesQuery,
|
||||||
|
entriesQuery,
|
||||||
|
selectedSource,
|
||||||
|
setSelectedSource,
|
||||||
|
selectedLevel,
|
||||||
|
setSelectedLevel,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
limit,
|
||||||
|
setLimit,
|
||||||
|
selectedEntryId: activeSelectedEntryId,
|
||||||
|
setSelectedEntryId,
|
||||||
|
selectedEntry,
|
||||||
|
latestTimestamp,
|
||||||
|
isInitialLoading:
|
||||||
|
(!configQuery.data && configQuery.isLoading) ||
|
||||||
|
(!sourcesQuery.data && sourcesQuery.isLoading) ||
|
||||||
|
(!entriesQuery.data && entriesQuery.isLoading),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateLogsConfig() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: UpdateLogsConfigPayload) => api.logs.updateConfig(payload),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: CONFIG_QUERY_KEY }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: SOURCES_QUERY_KEY }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['logs', 'entries'] }),
|
||||||
|
]);
|
||||||
|
toast.success('Logging configuration saved.');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || 'Failed to save logging configuration.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLogLevelOptions(): Array<{ value: LogsLevelFilter; label: string }> {
|
||||||
|
return [
|
||||||
|
{ value: 'all', label: 'All levels' },
|
||||||
|
{ value: 'error', label: 'Errors' },
|
||||||
|
{ value: 'warn', label: 'Warnings' },
|
||||||
|
{ value: 'info', label: 'Info' },
|
||||||
|
{ value: 'debug', label: 'Debug' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSelectedSourceLabel(
|
||||||
|
source: LogsSourceFilter,
|
||||||
|
sources: Array<{ source: string; label: string }>
|
||||||
|
) {
|
||||||
|
if (source === 'all') {
|
||||||
|
return 'All sources';
|
||||||
|
}
|
||||||
|
|
||||||
|
return sources.find((entry) => entry.source === source)?.label ?? source;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSourceLabelMap(
|
||||||
|
sources: Array<{ source: string; label: string }>
|
||||||
|
): Record<string, string> {
|
||||||
|
return Object.fromEntries(sources.map((source) => [source.source, source.label]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLogsEntryListEmpty(entries: LogsEntry[] | undefined) {
|
||||||
|
return !entries || entries.length === 0;
|
||||||
|
}
|
||||||
@@ -761,6 +761,46 @@ export interface UpdateAccountContext {
|
|||||||
continuity_mode?: 'standard' | 'deeper';
|
continuity_mode?: 'standard' | 'deeper';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LogsLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||||
|
|
||||||
|
export interface LogsConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
level: LogsLevel;
|
||||||
|
rotate_mb: number;
|
||||||
|
retain_days: number;
|
||||||
|
redact: boolean;
|
||||||
|
live_buffer_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogsSource {
|
||||||
|
source: string;
|
||||||
|
label: string;
|
||||||
|
kind: 'native' | 'legacy';
|
||||||
|
count: number;
|
||||||
|
lastTimestamp: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogsEntry {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
level: LogsLevel;
|
||||||
|
source: string;
|
||||||
|
event: string;
|
||||||
|
message: string;
|
||||||
|
processId: number | null;
|
||||||
|
runId: string | null;
|
||||||
|
context?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogsEntriesParams {
|
||||||
|
source?: string;
|
||||||
|
level?: LogsLevel;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateLogsConfigPayload = Partial<LogsConfig>;
|
||||||
|
|
||||||
// Unified config types
|
// Unified config types
|
||||||
export interface ConfigFormat {
|
export interface ConfigFormat {
|
||||||
format: 'yaml' | 'json' | 'none';
|
format: 'yaml' | 'json' | 'none';
|
||||||
@@ -964,6 +1004,37 @@ export const api = {
|
|||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
logs: {
|
||||||
|
getConfig: () => request<{ logging: LogsConfig }>('/logs/config'),
|
||||||
|
updateConfig: (data: UpdateLogsConfigPayload) =>
|
||||||
|
request<{ success: boolean; logging: LogsConfig }>('/logs/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
getSources: () => request<{ sources: LogsSource[] }>('/logs/sources'),
|
||||||
|
getEntries: ({ source, level, search, limit }: LogsEntriesParams = {}) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (source) {
|
||||||
|
params.set('source', source);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level) {
|
||||||
|
params.set('level', level);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
params.set('search', search);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof limit === 'number') {
|
||||||
|
params.set('limit', String(limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = params.toString();
|
||||||
|
return request<{ entries: LogsEntry[] }>(`/logs/entries${query ? `?${query}` : ''}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
cliproxy: {
|
cliproxy: {
|
||||||
list: () => request<{ variants: Variant[] }>('/cliproxy'),
|
list: () => request<{ variants: Variant[] }>('/cliproxy'),
|
||||||
getAuthStatus: () =>
|
getAuthStatus: () =>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const resources = {
|
|||||||
factoryDroid: 'Factory Droid',
|
factoryDroid: 'Factory Droid',
|
||||||
system: 'System',
|
system: 'System',
|
||||||
health: 'Health',
|
health: 'Health',
|
||||||
|
logs: 'Logs',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
openrouterTooltip: 'Featured: OpenRouter + Alibaba Coding Plan + Ollama',
|
openrouterTooltip: 'Featured: OpenRouter + Alibaba Coding Plan + Ollama',
|
||||||
},
|
},
|
||||||
|
|||||||
+23
-2
@@ -3,8 +3,9 @@ import { HeroSection } from '@/components/layout/hero-section';
|
|||||||
import { AuthMonitor } from '@/components/monitoring/auth-monitor';
|
import { AuthMonitor } from '@/components/monitoring/auth-monitor';
|
||||||
import { ErrorLogsMonitor } from '@/components/error-logs-monitor';
|
import { ErrorLogsMonitor } from '@/components/error-logs-monitor';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Key, Zap, Users, Activity, AlertTriangle } from 'lucide-react';
|
import { Key, Zap, Users, Activity, AlertTriangle, ArrowRight, ScrollText } from 'lucide-react';
|
||||||
import { useOverview } from '@/hooks/use-overview';
|
import { useOverview } from '@/hooks/use-overview';
|
||||||
import { useSharedSummary } from '@/hooks/use-shared';
|
import { useSharedSummary } from '@/hooks/use-shared';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -179,7 +180,27 @@ export function HomePage() {
|
|||||||
{/* Auth Monitor */}
|
{/* Auth Monitor */}
|
||||||
<AuthMonitor />
|
<AuthMonitor />
|
||||||
|
|
||||||
{/* Error Logs Monitor - shows only when there are errors */}
|
<div className="rounded-xl border bg-card/70 p-5">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="rounded-xl bg-muted p-2.5">
|
||||||
|
<ScrollText className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-lg font-semibold">Logs moved to a dedicated workspace</h2>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
Use the unified logs page for source-level filtering, structured entry inspection,
|
||||||
|
and retention policy edits without crowding the home dashboard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" className="gap-2" onClick={() => navigate('/logs')}>
|
||||||
|
Open logs
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ErrorLogsMonitor />
|
<ErrorLogsMonitor />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { AlertCircle, ArrowRight, RefreshCw } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { LogsConfigCard } from '@/components/logs/logs-config-card';
|
||||||
|
import { LogsDetailPanel } from '@/components/logs/logs-detail-panel';
|
||||||
|
import { LogsEntryList } from '@/components/logs/logs-entry-list';
|
||||||
|
import { LogsFilters } from '@/components/logs/logs-filters';
|
||||||
|
import { LogsOverviewCards } from '@/components/logs/logs-overview-cards';
|
||||||
|
import { LogsPageSkeleton } from '@/components/logs/logs-page-skeleton';
|
||||||
|
import { getSourceLabelMap, useLogsWorkspace, useUpdateLogsConfig } from '@/hooks/use-logs';
|
||||||
|
|
||||||
|
export function LogsPage() {
|
||||||
|
const workspace = useLogsWorkspace();
|
||||||
|
const updateConfig = useUpdateLogsConfig();
|
||||||
|
const sourceLabels = getSourceLabelMap(workspace.sourcesQuery.data ?? []);
|
||||||
|
const errors = [
|
||||||
|
workspace.configQuery.error,
|
||||||
|
workspace.sourcesQuery.error,
|
||||||
|
workspace.entriesQuery.error,
|
||||||
|
].filter(Boolean) as Error[];
|
||||||
|
|
||||||
|
if (workspace.isInitialLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-7xl mx-auto">
|
||||||
|
<LogsPageSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = workspace.configQuery.data;
|
||||||
|
if (!config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-7xl mx-auto space-y-6">
|
||||||
|
<div className="rounded-2xl border bg-gradient-to-br from-background via-background to-muted/40 p-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs uppercase tracking-[0.28em] text-muted-foreground">System logs</p>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight">Unified CCS logging</h1>
|
||||||
|
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||||
|
Watch current CCS activity across native and legacy emitters, inspect structured
|
||||||
|
context, and keep retention policy aligned with what the host actually stores.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => workspace.entriesQuery.refetch()}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={workspace.entriesQuery.isFetching ? 'h-4 w-4 animate-spin' : 'h-4 w-4'}
|
||||||
|
/>
|
||||||
|
Refresh entries
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="ghost" className="gap-2">
|
||||||
|
<Link to="/health">
|
||||||
|
Open health checks
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errors.length > 0 ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Unable to fully load the logs workspace</AlertTitle>
|
||||||
|
<AlertDescription>{errors[0]?.message}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<LogsOverviewCards
|
||||||
|
config={config}
|
||||||
|
sources={workspace.sourcesQuery.data ?? []}
|
||||||
|
entries={workspace.entriesQuery.data ?? []}
|
||||||
|
latestTimestamp={workspace.latestTimestamp}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_22rem]">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<LogsFilters
|
||||||
|
sources={workspace.sourcesQuery.data ?? []}
|
||||||
|
selectedSource={workspace.selectedSource}
|
||||||
|
onSourceChange={workspace.setSelectedSource}
|
||||||
|
selectedLevel={workspace.selectedLevel}
|
||||||
|
onLevelChange={workspace.setSelectedLevel}
|
||||||
|
search={workspace.search}
|
||||||
|
onSearchChange={workspace.setSearch}
|
||||||
|
limit={workspace.limit}
|
||||||
|
onLimitChange={workspace.setLimit}
|
||||||
|
onRefresh={() =>
|
||||||
|
void Promise.all([workspace.sourcesQuery.refetch(), workspace.entriesQuery.refetch()])
|
||||||
|
}
|
||||||
|
isRefreshing={workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
|
||||||
|
<LogsEntryList
|
||||||
|
entries={workspace.entriesQuery.data ?? []}
|
||||||
|
selectedEntryId={workspace.selectedEntryId}
|
||||||
|
onSelect={workspace.setSelectedEntryId}
|
||||||
|
sourceLabels={sourceLabels}
|
||||||
|
isLoading={workspace.entriesQuery.isLoading}
|
||||||
|
isFetching={workspace.entriesQuery.isFetching}
|
||||||
|
/>
|
||||||
|
<LogsDetailPanel
|
||||||
|
entry={workspace.selectedEntry}
|
||||||
|
sourceLabel={
|
||||||
|
workspace.selectedEntry ? sourceLabels[workspace.selectedEntry.source] : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LogsConfigCard
|
||||||
|
config={config}
|
||||||
|
onSave={(payload) => updateConfig.mutate(payload)}
|
||||||
|
isPending={updateConfig.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
|
||||||
|
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { LogsPage } from '@/pages/logs';
|
||||||
|
|
||||||
|
const fetchMock = vi.fn<typeof fetch>();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
if (!Element.prototype.hasPointerCapture) {
|
||||||
|
Element.prototype.hasPointerCapture = () => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Element.prototype.setPointerCapture) {
|
||||||
|
Element.prototype.setPointerCapture = () => undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Element.prototype.releasePointerCapture) {
|
||||||
|
Element.prototype.releasePointerCapture = () => undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Element.prototype.scrollIntoView) {
|
||||||
|
Element.prototype.scrollIntoView = () => undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEntries(source: string) {
|
||||||
|
if (source === 'agent-runner') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'entry-2',
|
||||||
|
timestamp: '2026-04-07T11:10:00.000Z',
|
||||||
|
level: 'warn',
|
||||||
|
source: 'agent-runner',
|
||||||
|
event: 'task.retry',
|
||||||
|
message: 'Worker retry scheduled',
|
||||||
|
processId: 'worker-1',
|
||||||
|
runId: 'run-2',
|
||||||
|
context: { attempt: 2, reason: 'network jitter' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'entry-1',
|
||||||
|
timestamp: '2026-04-07T11:00:00.000Z',
|
||||||
|
level: 'error',
|
||||||
|
source: 'dashboard',
|
||||||
|
event: 'logs.bootstrap',
|
||||||
|
message: 'Boot sequence failed for dashboard logging',
|
||||||
|
processId: 'api-1',
|
||||||
|
runId: 'run-1',
|
||||||
|
context: { component: 'dashboard', stage: 'bootstrap' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'entry-2',
|
||||||
|
timestamp: '2026-04-07T11:10:00.000Z',
|
||||||
|
level: 'warn',
|
||||||
|
source: 'agent-runner',
|
||||||
|
event: 'task.retry',
|
||||||
|
message: 'Worker retry scheduled',
|
||||||
|
processId: 'worker-1',
|
||||||
|
runId: 'run-2',
|
||||||
|
context: { attempt: 2, reason: 'network jitter' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function installFetchMock() {
|
||||||
|
fetchMock.mockImplementation((input) => {
|
||||||
|
const url =
|
||||||
|
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
const parsed = new URL(url, 'http://localhost');
|
||||||
|
|
||||||
|
if (parsed.pathname === '/api/logs/config') {
|
||||||
|
return jsonResponse({
|
||||||
|
logging: {
|
||||||
|
enabled: true,
|
||||||
|
level: 'info',
|
||||||
|
rotate_mb: 10,
|
||||||
|
retain_days: 7,
|
||||||
|
redact: true,
|
||||||
|
live_buffer_size: 250,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.pathname === '/api/logs/sources') {
|
||||||
|
return jsonResponse({
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
source: 'dashboard',
|
||||||
|
label: 'Dashboard UI',
|
||||||
|
kind: 'native',
|
||||||
|
count: 18,
|
||||||
|
lastTimestamp: '2026-04-07T11:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: 'agent-runner',
|
||||||
|
label: 'Agent Runner',
|
||||||
|
kind: 'legacy',
|
||||||
|
count: 9,
|
||||||
|
lastTimestamp: '2026-04-07T11:10:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.pathname === '/api/logs/entries') {
|
||||||
|
const source = parsed.searchParams.get('source') ?? 'all';
|
||||||
|
const search = parsed.searchParams.get('search');
|
||||||
|
const entries = buildEntries(source).filter((entry) =>
|
||||||
|
search ? entry.message.toLowerCase().includes(search.toLowerCase()) : true
|
||||||
|
);
|
||||||
|
return jsonResponse({ entries });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(new Error(`Unhandled request: ${url}`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LogsPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the loading skeleton while the initial queries are pending', () => {
|
||||||
|
fetchMock.mockImplementation(() => new Promise<Response>(() => {}));
|
||||||
|
|
||||||
|
render(<LogsPage />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Loading logs workspace')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by source and search query', async () => {
|
||||||
|
installFetchMock();
|
||||||
|
|
||||||
|
render(<LogsPage />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await screen.findAllByText('Boot sequence failed for dashboard logging')).length
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('combobox', { name: 'Source filter' }));
|
||||||
|
await userEvent.click(await screen.findByRole('option', { name: 'Agent Runner' }));
|
||||||
|
|
||||||
|
expect((await screen.findAllByText('Worker retry scheduled')).length).toBeGreaterThan(0);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryAllByText('Boot sequence failed for dashboard logging')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.clear(screen.getByLabelText('Search'));
|
||||||
|
await userEvent.type(screen.getByLabelText('Search'), 'retry');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
fetchMock.mock.calls.some((call) =>
|
||||||
|
String(call[0]).includes('/api/logs/entries?source=agent-runner')
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(fetchMock.mock.calls.some((call) => String(call[0]).includes('search=retry'))).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the selected entry detail and raw context', async () => {
|
||||||
|
installFetchMock();
|
||||||
|
|
||||||
|
render(<LogsPage />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await screen.findAllByText('Boot sequence failed for dashboard logging')).length
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Worker retry scheduled/i }));
|
||||||
|
|
||||||
|
expect((await screen.findAllByText('task.retry')).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText('worker-1')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('tab', { name: /Raw context/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByText(/"reason": "network jitter"/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/"runId": "run-2"/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user