From 6c799b2e58710149deb25399ad25dcb1bf5600d7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 7 Apr 2026 20:11:19 -0400 Subject: [PATCH] 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 --- src/ccs.ts | 11 + src/cliproxy/proxy-detector.ts | 8 +- src/cliproxy/startup-lock.ts | 6 +- src/cliproxy/tool-sanitization-proxy.ts | 7 + src/commands/cleanup-command.ts | 76 ++++--- src/commands/config-command.ts | 24 +++ src/config/unified-config-loader.ts | 37 ++++ src/config/unified-config-types.ts | 33 +++ src/errors/error-handler.ts | 14 ++ src/glmt/glmt-transformer.ts | 9 + src/services/logging/index.ts | 13 ++ src/services/logging/log-buffer.ts | 18 ++ src/services/logging/log-config.ts | 47 +++++ src/services/logging/log-paths.ts | 40 ++++ src/services/logging/log-reader.ts | 87 ++++++++ src/services/logging/log-redaction.ts | 62 ++++++ src/services/logging/log-storage.ts | 94 +++++++++ src/services/logging/log-types.ts | 47 +++++ src/services/logging/logger.ts | 67 ++++++ src/utils/websearch/trace.ts | 7 + src/web-server/index.ts | 16 ++ .../middleware/request-logging-middleware.ts | 30 +++ src/web-server/routes/index.ts | 2 + src/web-server/routes/logs-routes.ts | 99 +++++++++ .../services/logs-dashboard-service.ts | 37 ++++ src/web-server/websocket.ts | 38 ++-- tests/unit/services/logging/log-paths.test.ts | 46 +++++ tests/unit/web-server/logs-routes.test.ts | 139 +++++++++++++ ui/src/App.tsx | 9 + ui/src/components/layout/app-sidebar.tsx | 2 + ui/src/hooks/use-logs.ts | 149 +++++++++++++ ui/src/lib/api-client.ts | 71 +++++++ ui/src/lib/i18n.ts | 1 + ui/src/pages/home.tsx | 25 ++- ui/src/pages/logs.tsx | 130 ++++++++++++ ui/tests/unit/ui/pages/logs-page.test.tsx | 195 ++++++++++++++++++ 36 files changed, 1648 insertions(+), 48 deletions(-) create mode 100644 src/services/logging/index.ts create mode 100644 src/services/logging/log-buffer.ts create mode 100644 src/services/logging/log-config.ts create mode 100644 src/services/logging/log-paths.ts create mode 100644 src/services/logging/log-reader.ts create mode 100644 src/services/logging/log-redaction.ts create mode 100644 src/services/logging/log-storage.ts create mode 100644 src/services/logging/log-types.ts create mode 100644 src/services/logging/logger.ts create mode 100644 src/web-server/middleware/request-logging-middleware.ts create mode 100644 src/web-server/routes/logs-routes.ts create mode 100644 src/web-server/services/logs-dashboard-service.ts create mode 100644 tests/unit/services/logging/log-paths.test.ts create mode 100644 tests/unit/web-server/logs-routes.test.ts create mode 100644 ui/src/hooks/use-logs.ts create mode 100644 ui/src/pages/logs.tsx create mode 100644 ui/tests/unit/ui/pages/logs-page.test.tsx diff --git a/src/ccs.ts b/src/ccs.ts index 2d4caba9..a6edc978 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -68,6 +68,7 @@ import { tryHandleRootCommand } from './commands/root-command-router'; import { execClaude } from './utils/shell-executor'; import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation'; import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning'; +import { createLogger } from './services/logging'; // Import target adapter system import { @@ -321,6 +322,7 @@ async function main(): Promise { registerTarget(new ClaudeAdapter()); registerTarget(new DroidAdapter()); registerTarget(new CodexAdapter()); + const cliLogger = createLogger('cli'); const args = process.argv.slice(2); const isCompletionCommand = args[0] === '__complete'; @@ -398,6 +400,12 @@ async function main(): Promise { 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)) { execNativeCodexFlagCommand(args); return; @@ -448,6 +456,9 @@ async function main(): Promise { recovery.showRecoveryHints(); } } 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 console.warn('[!] Recovery failed:', (err as Error).message); } diff --git a/src/cliproxy/proxy-detector.ts b/src/cliproxy/proxy-detector.ts index 0e381778..8aae527f 100644 --- a/src/cliproxy/proxy-detector.ts +++ b/src/cliproxy/proxy-detector.ts @@ -19,6 +19,7 @@ import { getExistingProxy, registerSession, getRunningProxyVersion } from './ses import { isCliproxyRunning } from './stats-fetcher'; import { getPortProcess, isCLIProxyProcess, PortProcess } from '../utils/port-utils'; import { CLIPROXY_DEFAULT_PORT } from './config-generator'; +import { createLogger } from '../services/logging'; /** Detection method used to find the proxy */ 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 */ const noopLog: LogFn = () => {}; +const logger = createLogger('cliproxy:proxy-detector'); /** * Detect running CLIProxy using multiple methods with fallbacks. @@ -65,7 +67,7 @@ export async function detectRunningProxy( port: number = CLIPROXY_DEFAULT_PORT, verbose: boolean = false ): Promise { - 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 const validPort = @@ -235,7 +237,9 @@ export function reclaimOrphanedProxy( pid: number, verbose: boolean = false ): 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 { log(`Reclaiming orphaned proxy: port=${port}, pid=${pid}`); diff --git a/src/cliproxy/startup-lock.ts b/src/cliproxy/startup-lock.ts index 8d63e3d0..1bf6b569 100644 --- a/src/cliproxy/startup-lock.ts +++ b/src/cliproxy/startup-lock.ts @@ -25,6 +25,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCliproxyDir } from './config-generator'; +import { createLogger } from '../services/logging'; /** Lock file structure */ interface LockData { @@ -51,6 +52,7 @@ type LogFn = (msg: string) => void; /** No-op logger for when verbose is disabled */ const noopLog: LogFn = () => {}; +const logger = createLogger('cliproxy:startup-lock'); /** * Get path to startup lock file @@ -184,7 +186,9 @@ export async function acquireStartupLock(options?: { }): Promise { const retries = options?.retries ?? 20; 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)`); diff --git a/src/cliproxy/tool-sanitization-proxy.ts b/src/cliproxy/tool-sanitization-proxy.ts index ace07e4b..fac34d03 100644 --- a/src/cliproxy/tool-sanitization-proxy.ts +++ b/src/cliproxy/tool-sanitization-proxy.ts @@ -26,6 +26,7 @@ import { } from './model-id-normalizer'; import { getModelMaxLevel } from './model-catalog'; import { getCcsDir } from '../utils/config-manager'; +import { createLogger } from '../services/logging'; export interface ToolSanitizationProxyConfig { /** Upstream CLIProxy URL */ @@ -153,6 +154,7 @@ export class ToolSanitizationProxy { private readonly config: Required; private readonly logFilePath: string; private readonly debugMode: boolean; + private readonly logger = createLogger('cliproxy:tool-sanitization-proxy'); constructor(config: ToolSanitizationProxyConfig) { this.config = { @@ -207,6 +209,11 @@ export class ToolSanitizationProxy { if (this.debugMode) { console.error(`${prefix} ${message}`); } + + this.logger[level](level, message, { + debugMode: this.debugMode, + logFilePath: this.logFilePath, + }); } private log(message: string): void { diff --git a/src/commands/cleanup-command.ts b/src/commands/cleanup-command.ts index 20c23cfa..a695b1d3 100644 --- a/src/commands/cleanup-command.ts +++ b/src/commands/cleanup-command.ts @@ -9,6 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCliproxyDir } from '../cliproxy/config-generator'; +import { getNativeLogsDir } from '../services/logging'; import { info, ok, warn } from '../utils/ui'; /** Default age in days for error log cleanup */ @@ -19,6 +20,10 @@ function getLogsDir(): string { return path.join(getCliproxyDir(), 'logs'); } +function getCcsLogsDir(): string { + return getNativeLogsDir(); +} + /** Format bytes to human-readable size */ function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; @@ -174,18 +179,18 @@ function printHelp(): void { console.log(''); console.log('Usage: ccs cleanup [options]'); 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('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(' --dry-run Show what would be deleted without deleting'); console.log(' --force Skip confirmation prompt'); console.log(' --help, -h Show this help message'); console.log(''); console.log('Examples:'); - console.log(' ccs cleanup Interactive main log cleanup'); - console.log(' ccs cleanup --errors Clean error logs older than 7 days'); + console.log(' ccs cleanup Interactive CCS + CLIProxy log cleanup'); + 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 --dry-run Preview error log cleanup'); console.log(' ccs cleanup --dry-run Preview main log cleanup'); @@ -207,6 +212,7 @@ export async function handleCleanupCommand(args: string[]): Promise { const force = args.includes('--force'); const cleanErrors = args.includes('--errors'); const logsDir = getLogsDir(); + const ccsLogsDir = getCcsLogsDir(); // Parse --days=N option let maxAgeDays = DEFAULT_ERROR_LOG_AGE_DAYS; @@ -224,7 +230,7 @@ export async function handleCleanupCommand(args: string[]): Promise { if (cleanErrors) { await handleErrorLogCleanup(logsDir, maxAgeDays, dryRun, force); } 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) */ -async function handleMainLogCleanup( - logsDir: string, - dryRun: boolean, - force: boolean -): Promise { - // Check if logs directory exists - if (!fs.existsSync(logsDir)) { - console.log(info('No CLIProxy logs found.')); +async function handleMainLogCleanup(options: { + cliproxyLogsDir: string; + ccsLogsDir: string; + dryRun: boolean; + force: boolean; +}): Promise { + const targets = [ + { 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; } - // Calculate current size - const currentSize = getDirSize(logsDir); - const fileCount = countFiles(logsDir); + const currentSize = activeTargets.reduce((sum, target) => sum + target.size, 0); + const fileCount = activeTargets.reduce((sum, target) => sum + target.fileCount, 0); - if (fileCount === 0) { - console.log(info('No log files to clean.')); - return; + console.log(''); + console.log('Log Cleanup Targets:'); + 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(''); - if (dryRun) { + if (options.dryRun) { console.log(info('Dry run - no files deleted.')); console.log(`Would delete ${fileCount} files (${formatBytes(currentSize)})`); return; } // Confirm unless --force - if (!force) { + if (!options.force) { const readline = await import('readline'); const rl = readline.createInterface({ input: process.stdin, @@ -371,13 +383,19 @@ async function handleMainLogCleanup( } // 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)}`)); // Suggest disabling logging if it was enabled if (deleted > 0) { console.log(''); - console.log(warn('Tip: CLIProxy logging is now disabled by default.')); - console.log(' Run `ccs doctor --fix` to update your config.'); + console.log(warn('Tip: CCS logging is bounded by retention, but you can lower it further.')); + console.log(' Open `ccs config` and review the Logs settings.'); } } diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index fae58460..952dd81f 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -22,6 +22,9 @@ import { resolveDashboardUrls, } from './config-dashboard-host'; import { parseConfigCommandArgs, showConfigCommandHelp } from './config-command-options'; +import { createLogger } from '../services/logging'; + +const logger = createLogger('command:config'); const CONFIG_SUBCOMMAND_ROUTES: readonly NamedCommandRoute[] = [ { @@ -123,6 +126,11 @@ export async function handleConfigCommand( const options = parsed.options; 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(''); @@ -130,6 +138,13 @@ export async function handleConfigCommand( // Ensure CLIProxy service is running for dashboard features console.log(deps.info('Starting CLIProxy service...')); 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.alreadyRunning) { @@ -210,14 +225,23 @@ export async function handleConfigCommand( // Open browser try { 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')); } catch { + logger.warn('dashboard.browser_open_failed', 'Automatic browser launch failed', { + browserUrl: urls.browserUrl, + }); console.log(deps.info(`Open manually: ${urls.browserUrl}`)); } console.log(''); console.log(deps.info('Press Ctrl+C to stop')); } 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}`)); process.exit(1); } diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 95ec4fcd..b0c3ecfd 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -24,6 +24,7 @@ import { DEFAULT_OFFICIAL_CHANNELS_CONFIG, DEFAULT_DASHBOARD_AUTH_CONFIG, DEFAULT_IMAGE_ANALYSIS_CONFIG, + DEFAULT_LOGGING_CONFIG, } from './unified-config-types'; import type { UnifiedConfig, @@ -34,6 +35,7 @@ import type { OfficialChannelId, DashboardAuthConfig, ImageAnalysisConfig, + LoggingConfig, CursorConfig, ContinuityConfig, } from './unified-config-types'; @@ -381,6 +383,15 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { : 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: { ...defaults.preferences, ...partial.preferences, @@ -657,6 +668,19 @@ function generateYamlWithComments(config: UnifiedConfig): string { ); 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 if (config.cliproxy_server) { 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. * Returns defaults if not configured. diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index d594d984..5ce9c304 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -234,6 +234,36 @@ export interface CLIProxyConfig { 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. */ @@ -812,6 +842,8 @@ export interface UnifiedConfig { profiles: Record; /** CLIProxy configuration */ cliproxy: CLIProxyConfig; + /** CCS-owned structured logging configuration */ + logging?: LoggingConfig; /** User preferences */ preferences: PreferencesConfig; /** WebSearch configuration */ @@ -912,6 +944,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { strategy: 'round-robin', }, }, + logging: { ...DEFAULT_LOGGING_CONFIG }, preferences: { theme: 'system', telemetry: false, diff --git a/src/errors/error-handler.ts b/src/errors/error-handler.ts index a5adf988..4bb8f6be 100644 --- a/src/errors/error-handler.ts +++ b/src/errors/error-handler.ts @@ -11,6 +11,9 @@ import { ExitCode, EXIT_CODE_DESCRIPTIONS } from './exit-codes'; import { isCCSError } from './error-types'; 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 @@ -91,6 +94,10 @@ export function handleError(error: unknown): never { const code = getExitCode(error); const message = formatErrorMessage(error); + logger.error('command.unhandled_error', 'Unhandled CLI error', { + exitCode: code, + error, + }); // Output error message to stderr console.error(message); @@ -112,6 +119,10 @@ export function handleError(error: unknown): never { */ export function exitWithError(message: string, code: ExitCode = ExitCode.GENERAL_ERROR): never { runCleanup(); + logger.error('command.exit_error', 'CLI exited with error', { + exitCode: code, + message, + }); console.error(`[X] ${message}`); if (isDebugMode()) { @@ -131,6 +142,9 @@ export function exitWithError(message: string, code: ExitCode = ExitCode.GENERAL */ export function exitWithSuccess(message?: string): never { runCleanup(); + logger.info('command.exit_success', 'CLI exited successfully', { + message: message || null, + }); if (message) { console.log(`[OK] ${message}`); } diff --git a/src/glmt/glmt-transformer.ts b/src/glmt/glmt-transformer.ts index afa4fe8c..1886f05a 100644 --- a/src/glmt/glmt-transformer.ts +++ b/src/glmt/glmt-transformer.ts @@ -12,6 +12,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { DeltaAccumulator } from './delta-accumulator'; import { getCcsDir } from '../utils/config-manager'; +import { createLogger } from '../services/logging'; import { RequestTransformer, StreamParser, @@ -36,6 +37,7 @@ export class GlmtTransformer { private verbose: boolean; private debugLog: boolean; debugLogDir: string; + private readonly logger = createLogger('glmt:transformer'); private requestTransformer: RequestTransformer; private streamParser: StreamParser; @@ -125,6 +127,9 @@ export class GlmtTransformer { return anthropicResponse; } catch (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); return { id: 'msg_error_' + Date.now(), @@ -175,12 +180,16 @@ export class GlmtTransformer { const redacted = this.redactSensitiveData(data); fs.writeFileSync(filepath, JSON.stringify(redacted, null, 2) + '\n', 'utf8'); } 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}`); } } private log(message: string): void { if (this.verbose) { + this.logger.debug('transformer.verbose', message); console.error(`[glmt-transformer] [${new Date().toTimeString().split(' ')[0]}] ${message}`); } } diff --git a/src/services/logging/index.ts b/src/services/logging/index.ts new file mode 100644 index 00000000..68e4b6ad --- /dev/null +++ b/src/services/logging/index.ts @@ -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'; diff --git a/src/services/logging/log-buffer.ts b/src/services/logging/log-buffer.ts new file mode 100644 index 00000000..965140f6 --- /dev/null +++ b/src/services/logging/log-buffer.ts @@ -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 = []; +} diff --git a/src/services/logging/log-config.ts b/src/services/logging/log-config.ts new file mode 100644 index 00000000..3985d25e --- /dev/null +++ b/src/services/logging/log-config.ts @@ -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; + } +} diff --git a/src/services/logging/log-paths.ts b/src/services/logging/log-paths.ts new file mode 100644 index 00000000..3bc30c57 --- /dev/null +++ b/src/services/logging/log-paths.ts @@ -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`); +} diff --git a/src/services/logging/log-reader.ts b/src/services/logging/log-reader.ts new file mode 100644 index 00000000..8485e8e8 --- /dev/null +++ b/src/services/logging/log-reader.ts @@ -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(); + 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(); + 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; +} diff --git a/src/services/logging/log-redaction.ts b/src/services/logging/log-redaction.ts new file mode 100644 index 00000000..ca8446f3 --- /dev/null +++ b/src/services/logging/log-redaction.ts @@ -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 = {}; + for (const [key, nestedValue] of Object.entries(value as Record)) { + sanitized[key] = SENSITIVE_KEY_PATTERN.test(key) + ? '[redacted]' + : sanitizeValue(nestedValue, depth + 1); + } + return sanitized; + } + + return String(value); +} + +export function redactContext( + context: Record | undefined +): Record { + if (!context) { + return {}; + } + + return sanitizeValue(context, 0) as Record; +} diff --git a/src/services/logging/log-storage.ts b/src/services/logging/log-storage.ts new file mode 100644 index 00000000..dc8af66b --- /dev/null +++ b/src/services/logging/log-storage.ts @@ -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. + } +} diff --git a/src/services/logging/log-types.ts b/src/services/logging/log-types.ts new file mode 100644 index 00000000..a3e08c16 --- /dev/null +++ b/src/services/logging/log-types.ts @@ -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; +} + +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 = { + 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); +} diff --git a/src/services/logging/logger.ts b/src/services/logging/logger.ts new file mode 100644 index 00000000..3506871c --- /dev/null +++ b/src/services/logging/logger.ts @@ -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 +): 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): Logger; + debug(event: string, message: string, context?: Record): void; + info(event: string, message: string, context?: Record): void; + warn(event: string, message: string, context?: Record): void; + error(event: string, message: string, context?: Record): void; +} + +export function createLogger(source: string, baseContext: Record = {}): Logger { + const write = ( + level: LoggingLevel, + event: string, + message: string, + context?: Record + ) => { + appendStructuredLogEntry( + createEntry(source, level, event, message, { ...baseContext, ...(context || {}) }) + ); + }; + + return { + child(context: Record) { + 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); + }, + }; +} diff --git a/src/utils/websearch/trace.ts b/src/utils/websearch/trace.ts index 6f4f9880..5629be7a 100644 --- a/src/utils/websearch/trace.ts +++ b/src/utils/websearch/trace.ts @@ -10,6 +10,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { getCcsDir } from '../config-manager'; +import { createLogger } from '../../services/logging'; const TRACE_FILE_NAME = 'websearch-trace.jsonl'; const NATIVE_WEBSEARCH_TOOL = 'WebSearch'; @@ -17,6 +18,7 @@ const DISALLOWED_TOOLS_FLAG = '--disallowedTools'; const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-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.'; +const logger = createLogger('websearch'); function parseToolValue(rawValue: string): string[] { return rawValue @@ -121,6 +123,11 @@ export function appendWebSearchTrace( } try { + logger.info('trace.append', 'WebSearch trace event recorded', { + event, + launchId: env.CCS_WEBSEARCH_TRACE_LAUNCH_ID || null, + payload, + }); const traceFilePath = getTraceFilePath(env); fs.mkdirSync(path.dirname(traceFilePath), { recursive: true }); fs.appendFileSync( diff --git a/src/web-server/index.ts b/src/web-server/index.ts index 7053b231..fd2de5a6 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -12,8 +12,10 @@ import path from 'path'; import { WebSocketServer } from 'ws'; import { setupWebSocket } from './websocket'; import { createSessionMiddleware, authMiddleware } from './middleware/auth-middleware'; +import { requestLoggingMiddleware } from './middleware/request-logging-middleware'; import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync'; import { shutdownUsageAggregator } from './usage/aggregator'; +import { createLogger } from '../services/logging'; export interface ServerOptions { port: number; @@ -28,6 +30,8 @@ export interface ServerInstance { cleanup: () => void; } +const logger = createLogger('web-server'); + /** * Start Express server with WebSocket support */ @@ -57,6 +61,7 @@ export async function startServer(options: ServerOptions): Promise((resolve, reject) => { 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(); reject(new Error(formatListenError(error, options))); }; @@ -132,6 +143,11 @@ export async function startServer(options: ServerOptions): Promise { 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 // This keeps server startup instant for users who don't need analytics resolve({ server, wss, cleanup }); diff --git a/src/web-server/middleware/request-logging-middleware.ts b/src/web-server/middleware/request-logging-middleware.ts new file mode 100644 index 00000000..9faca1e5 --- /dev/null +++ b/src/web-server/middleware/request-logging-middleware.ts @@ -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(); +} diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index d1e9e5fe..5a1d7f76 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -34,6 +34,7 @@ import authRoutes from './auth-routes'; import persistRoutes from './persist-routes'; import catalogRoutes from './catalog-routes'; import claudeExtensionRoutes from './claude-extension-routes'; +import logsRoutes from './logs-routes'; // Create the main API router export const apiRoutes = Router(); @@ -115,3 +116,4 @@ apiRoutes.use('/cliproxy-server', cliproxyServerRoutes); // ==================== Misc (File API, Global Env) ==================== apiRoutes.use('/', miscRoutes); +apiRoutes.use('/logs', logsRoutes); diff --git a/src/web-server/routes/logs-routes.ts b/src/web-server/routes/logs-routes.ts new file mode 100644 index 00000000..15106d07 --- /dev/null +++ b/src/web-server/routes/logs-routes.ts @@ -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; + 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; diff --git a/src/web-server/services/logs-dashboard-service.ts b/src/web-server/services/logs-dashboard-service.ts new file mode 100644 index 00000000..58c6a84c --- /dev/null +++ b/src/web-server/services/logs-dashboard-service.ts @@ -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 { + 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); +} diff --git a/src/web-server/websocket.ts b/src/web-server/websocket.ts index b9ae862e..b99710ce 100644 --- a/src/web-server/websocket.ts +++ b/src/web-server/websocket.ts @@ -7,12 +7,14 @@ import { WebSocketServer, WebSocket } from 'ws'; import { createFileWatcher, FileChangeEvent } from './file-watcher'; -import { info, warn } from '../utils/ui'; import { projectSelectionEvents, type ProjectSelectionPrompt, } from '../cliproxy/project-selection-handler'; import { deviceCodeEvents, type DeviceCodePrompt } from '../cliproxy/device-code-handler'; +import { createLogger } from '../services/logging'; + +const logger = createLogger('web-server:websocket'); export interface WSMessage { type: string; @@ -36,7 +38,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } { // Handle new connections wss.on('connection', (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 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()); handleClientMessage(ws, message); } catch { - console.log(warn('[WS] Invalid message format')); + logger.warn('message.invalid', 'WebSocket client sent invalid JSON'); } }); // Handle disconnect ws.on('close', () => { 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) => { - console.log(warn(`[WS] Error: ${err.message}`)); + logger.warn('client.error', 'WebSocket client error', { message: err.message }); clients.delete(ws); }); }); @@ -73,13 +77,15 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } { // Future: selective subscriptions break; 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 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({ type: event.type, path: event.path, @@ -89,7 +95,9 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } { // Listen for project selection events and broadcast to clients 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({ type: 'projectSelectionRequired', ...prompt, @@ -98,7 +106,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => 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({ type: 'projectSelectionTimeout', sessionId, @@ -110,7 +118,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } { sessionId: string; selectedId: string; }): void => { - console.log(info(`[WS] Project selection submitted (session: ${response.sessionId})`)); + logger.info('project-selection.submitted', 'Project selection submitted', response); broadcast({ type: 'projectSelectionSubmitted', ...response, @@ -120,7 +128,9 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } { // Listen for device code events and broadcast to clients 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({ type: 'deviceCodeReceived', ...prompt, @@ -129,7 +139,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => 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({ type: 'deviceCodeCompleted', sessionId, @@ -138,7 +148,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => 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({ type: 'deviceCodeFailed', ...data, @@ -147,7 +157,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => 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({ type: 'deviceCodeExpired', sessionId, diff --git a/tests/unit/services/logging/log-paths.test.ts b/tests/unit/services/logging/log-paths.test.ts new file mode 100644 index 00000000..4e1ce391 --- /dev/null +++ b/tests/unit/services/logging/log-paths.test.ts @@ -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 + ); + }); +}); diff --git a/tests/unit/web-server/logs-routes.test.ts b/tests/unit/web-server/logs-routes.test.ts new file mode 100644 index 00000000..56ab9b32 --- /dev/null +++ b/tests/unit/web-server/logs-routes.test.ts @@ -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((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((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.', + }); + }); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ba45cce4..16dc92ed 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -36,6 +36,7 @@ const ClaudeExtensionPage = lazy(() => ); const CodexPage = lazy(() => import('@/pages/codex').then((m) => ({ default: m.CodexPage }))); 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(() => import('@/pages/accounts').then((m) => ({ default: m.AccountsPage })) ); @@ -182,6 +183,14 @@ export default function App() { } /> + }> + + + } + /> string): SidebarGroupDef[] { title: t('nav.system'), items: [ { path: '/health', icon: Activity, label: t('nav.health') }, + { path: '/logs', icon: ScrollText, label: t('nav.logs') }, { path: '/settings', icon: Settings, label: t('nav.settings') }, ], }, diff --git a/ui/src/hooks/use-logs.ts b/ui/src/hooks/use-logs.ts new file mode 100644 index 00000000..2f63f4fa --- /dev/null +++ b/ui/src/hooks/use-logs.ts @@ -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('all'); + const [selectedLevel, setSelectedLevel] = useState('all'); + const [search, setSearch] = useState(''); + const [limit, setLimit] = useState(DEFAULT_LIMIT); + const [selectedEntryId, setSelectedEntryId] = useState(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 { + return Object.fromEntries(sources.map((source) => [source.source, source.label])); +} + +export function isLogsEntryListEmpty(entries: LogsEntry[] | undefined) { + return !entries || entries.length === 0; +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index e5c83f6a..12c483de 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -761,6 +761,46 @@ export interface UpdateAccountContext { 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; + // Unified config types export interface ConfigFormat { format: 'yaml' | 'json' | 'none'; @@ -964,6 +1004,37 @@ export const api = { 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: { list: () => request<{ variants: Variant[] }>('/cliproxy'), getAuthStatus: () => diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 70b049a8..72e73701 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -33,6 +33,7 @@ const resources = { factoryDroid: 'Factory Droid', system: 'System', health: 'Health', + logs: 'Logs', settings: 'Settings', openrouterTooltip: 'Featured: OpenRouter + Alibaba Coding Plan + Ollama', }, diff --git a/ui/src/pages/home.tsx b/ui/src/pages/home.tsx index a60041ae..42f2f231 100644 --- a/ui/src/pages/home.tsx +++ b/ui/src/pages/home.tsx @@ -3,8 +3,9 @@ import { HeroSection } from '@/components/layout/hero-section'; import { AuthMonitor } from '@/components/monitoring/auth-monitor'; import { ErrorLogsMonitor } from '@/components/error-logs-monitor'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; 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 { useSharedSummary } from '@/hooks/use-shared'; import { cn } from '@/lib/utils'; @@ -179,7 +180,27 @@ export function HomePage() { {/* Auth Monitor */} - {/* Error Logs Monitor - shows only when there are errors */} +
+
+
+
+ +
+
+

Logs moved to a dedicated workspace

+

+ Use the unified logs page for source-level filtering, structured entry inspection, + and retention policy edits without crowding the home dashboard. +

+
+
+ +
+
+ ); diff --git a/ui/src/pages/logs.tsx b/ui/src/pages/logs.tsx new file mode 100644 index 00000000..943a89f0 --- /dev/null +++ b/ui/src/pages/logs.tsx @@ -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 ( +
+ +
+ ); + } + + const config = workspace.configQuery.data; + if (!config) { + return null; + } + + return ( +
+
+
+
+

System logs

+
+

Unified CCS logging

+

+ Watch current CCS activity across native and legacy emitters, inspect structured + context, and keep retention policy aligned with what the host actually stores. +

+
+
+
+ + +
+
+
+ + {errors.length > 0 ? ( + + + Unable to fully load the logs workspace + {errors[0]?.message} + + ) : null} + + + +
+
+ + void Promise.all([workspace.sourcesQuery.refetch(), workspace.entriesQuery.refetch()]) + } + isRefreshing={workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching} + /> + +
+ + +
+
+ + updateConfig.mutate(payload)} + isPending={updateConfig.isPending} + /> +
+
+ ); +} diff --git a/ui/tests/unit/ui/pages/logs-page.test.tsx b/ui/tests/unit/ui/pages/logs-page.test.tsx new file mode 100644 index 00000000..d4227e8c --- /dev/null +++ b/ui/tests/unit/ui/pages/logs-page.test.tsx @@ -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(); + +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(() => {})); + + render(); + + expect(screen.getByLabelText('Loading logs workspace')).toBeInTheDocument(); + }); + + it('filters by source and search query', async () => { + installFetchMock(); + + render(); + + 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(); + + 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(); + }); +});