diff --git a/docs/code-standards.md b/docs/code-standards.md index 7e45c745..3c3b35cb 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -1,6 +1,6 @@ # CCS Code Standards -Last Updated: 2026-02-04 +Last Updated: 2026-04-07 Code standards, modularization patterns, and conventions for the CCS codebase. @@ -383,6 +383,14 @@ export type { ## Terminal Output Standards +### CCS Logging Standards + +- Use the shared logger from `src/services/logging/` for CCS-owned runtime diagnostics, request tracing, and structured events. +- Keep `utils/ui` and deliberate `console.log`/`console.error` output for user-facing CLI UX only. +- Redact secrets before persistence; never write raw tokens, cookies, API keys, or password hashes into CCS-owned logs. +- Persist CCS-owned logs only under `getCcsDir()/logs`; do not invent per-feature log roots. +- When adding dashboard polling or diagnostics routes, prevent them from recursively logging the log viewer itself. + ### ASCII Only ```typescript diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index dbe3f73b..71d2c34a 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -1,6 +1,6 @@ # CCS Codebase Summary -Last Updated: 2026-03-28 +Last Updated: 2026-04-07 Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, account-context validation hardening, Official Claude Channels runtime support, and native Codex runtime target support. @@ -269,6 +269,14 @@ src/ - Auto-enable is gated on Bun availability, verified Claude Code v2.1.80+, verified `claude.ai` auth, native Claude `default/account` sessions, and per-channel setup readiness. - The dashboard channels section surfaces Bun/version/auth/state-scope status from `/api/channels`, preserves token drafts when save-follow-up refresh fails, and keeps unsupported selected iMessage visible only so it can be turned off. +### Structured Logging Domain + +- CCS-owned runtime logging now lives in `src/services/logging/`. +- The shared domain owns path resolution, redaction, rotation/pruning, buffered recent-entry reads, and the logger factory used by CLI/server/runtime code. +- Dashboard exposure lives in `src/web-server/routes/logs-routes.ts`, `src/web-server/services/logs-dashboard-service.ts`, and `src/web-server/middleware/request-logging-middleware.ts`. +- The native dashboard viewer lives at `ui/src/pages/logs.tsx` with supporting components under `ui/src/components/logs/` and hooks in `ui/src/hooks/use-logs.ts`. +- Legacy CLIProxy error files still exist under `~/.ccs/cliproxy/logs` and are surfaced as a labeled legacy source rather than the primary CCS logging model. + ### Target Adapter Module The targets module provides an extensible interface for dispatching profiles to different CLI implementations. diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index bd498842..7a0a0e0f 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -43,6 +43,7 @@ All major modularization work is complete. The codebase evolved from monolithic - **2026-04-08**: **#929** Image Analysis hardening now makes the managed `ccs-image-analysis` MCP path authoritative on healthy Claude-target launches, suppresses stale CCS-managed image `Read` hooks instead of letting them compete with MCP, keeps the legacy hook available only as compatibility fallback when MCP provisioning fails, and extends self-heal to dashboard provisioning plus `ccs doctor --fix` so stale hook files and missing isolated MCP sync are repaired automatically. - **2026-04-07**: CLIProxy routing strategy is now a first-class CCS surface. Users can inspect and explicitly change `round-robin` vs `fill-first` from `ccs cliproxy routing` and from a native `/cliproxy` dashboard card. Local mode now persists the chosen startup default into CCS-managed CLIProxy config generation, while untouched installs remain on `round-robin`. CCS deliberately does not infer strategy from account composition. +- **2026-04-07**: **#926** CCS now has a first-class structured logging layer under `src/services/logging/`, a bounded top-level `logging` config section in `~/.ccs/config.yaml`, automatic rotation/retention for CCS-owned logs under `~/.ccs/logs/`, native `/api/logs` dashboard endpoints, request tracing for the dashboard backend, and a dedicated `System -> Logs` dashboard route for browsing recent entries and editing retention settings. Legacy CLIProxy error files remain available as a labeled legacy source instead of acting as the primary logging model. - **2026-04-06**: The dashboard login surface now distinguishes a real sign-in from a host-setup requirement. Remote/IP visitors no longer see a misleading blank credential form when dashboard auth is disabled or incomplete; they now get explicit guidance that CCS has no default credentials, should be enabled on the host with `ccs config auth setup`, or should be reopened via localhost when used on the same machine. The password field now includes a show/hide toggle, and the page exposes an explicit light/dark theme switch before sign-in. - **2026-04-04**: The GitHub README was reduced from a wall-of-text reference dump into a shorter conversion surface that keeps the hero, proof screenshots, and fast-start commands while delegating deeper installation, provider, feature, and CLI-reference content to `docs.ccs.kaitran.ca`. The docs site now includes a dedicated `Product Tour` page for the screenshot-led walkthrough. - **2026-04-05**: **#912 #913 #914** Kiro auth is now aligned with the current CLIProxyAPIPlus contract. CCS auto-selects the Builder ID path for the default `ccs kiro --auth` flow instead of stalling on the upstream Builder ID vs IDC chooser, callback-based Kiro auth methods can use `--paste-callback` by replaying the pasted redirect URL back into the local callback server, and the CLI now supports IDC auth via `--kiro-auth-method idc` plus `--kiro-idc-start-url`, `--kiro-idc-region`, and `--kiro-idc-flow`. diff --git a/docs/system-architecture/index.md b/docs/system-architecture/index.md index 97ad2013..685d31ef 100644 --- a/docs/system-architecture/index.md +++ b/docs/system-architecture/index.md @@ -1,6 +1,6 @@ # CCS System Architecture -Last Updated: 2026-03-28 +Last Updated: 2026-04-07 High-level architecture overview for the CCS (Claude Code Switch) system. @@ -18,6 +18,7 @@ The system consists of two main components: Dashboard localization (i18n) architecture and contributor workflow are documented in [Dashboard i18n Guide](../i18n-dashboard.md). CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy with automatic injection for all profile types. +CCS v7.67 adds a native structured logging lane for CCS-owned runtime events, backed by `src/services/logging/`, bounded JSONL files under `~/.ccs/logs/`, and a dedicated dashboard `/logs` route. ``` +===========================================================================+ @@ -216,6 +217,14 @@ For detailed provider flows (CLIProxyAPI, legacy GLMT compatibility, quota manag ## Configuration Architecture +### CCS Logging Architecture + +- Shared logging contract lives in `src/services/logging/` and is used for CCS-owned runtime diagnostics, request tracing, and bounded recent-entry reads. +- Config lives at top-level `logging.*` in `~/.ccs/config.yaml`; `cliproxy.logging.*` still controls upstream CLIProxy runtime files only. +- CCS-owned runtime logs write to `~/.ccs/logs/current.jsonl` and rotate into `~/.ccs/logs/archive/` based on policy. +- Dashboard exposure uses native `/api/logs/config`, `/api/logs/sources`, and `/api/logs/entries` endpoints plus the `System -> Logs` React page. +- Request logging explicitly skips `/api/logs` reads so the log viewer does not recursively log itself. + ### Config File Hierarchy ``` 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..223ae502 100644 --- a/src/commands/cleanup-command.ts +++ b/src/commands/cleanup-command.ts @@ -1,7 +1,7 @@ /** * Cleanup Command Handler * - * Removes old CLIProxy logs to free up disk space. + * Removes old CCS and CLIProxy logs to free up disk space. * Supports both main logs and error request logs with age-based filtering. * Logs can accumulate to several GB without user awareness. */ @@ -9,6 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCliproxyDir } from '../cliproxy/config-generator'; +import { getLogArchiveDir, getNativeLogsDir } from '../services/logging'; import { info, ok, warn } from '../utils/ui'; /** Default age in days for error log cleanup */ @@ -19,6 +20,14 @@ function getLogsDir(): string { return path.join(getCliproxyDir(), 'logs'); } +function getCcsLogsDir(): string { + return getNativeLogsDir(); +} + +function getCcsLogArchiveDir(): string { + return getLogArchiveDir(); +} + /** Format bytes to human-readable size */ function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; @@ -27,23 +36,20 @@ function formatBytes(bytes: number): string { return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`; } -/** Calculate total size of a directory */ +/** Calculate total size of regular top-level files in a directory */ function getDirSize(dirPath: string): number { if (!fs.existsSync(dirPath)) return 0; let totalSize = 0; - const files = fs.readdirSync(dirPath); + const entries = fs.readdirSync(dirPath); - for (const file of files) { - const filePath = path.join(dirPath, file); + for (const entry of entries) { + const filePath = path.join(dirPath, entry); try { - const stats = fs.lstatSync(filePath); // Use lstat to detect symlinks + const stats = fs.lstatSync(filePath); if (stats.isFile() && !stats.isSymbolicLink()) { totalSize += stats.size; - } else if (stats.isDirectory() && !stats.isSymbolicLink()) { - totalSize += getDirSize(filePath); } - // Skip symlinks for safety } catch { // File may have been deleted between readdir and stat - skip } @@ -174,18 +180,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 +213,8 @@ export async function handleCleanupCommand(args: string[]): Promise { const force = args.includes('--force'); const cleanErrors = args.includes('--errors'); const logsDir = getLogsDir(); + const ccsLogsDir = getCcsLogsDir(); + const ccsArchiveDir = getCcsLogArchiveDir(); // Parse --days=N option let maxAgeDays = DEFAULT_ERROR_LOG_AGE_DAYS; @@ -224,7 +232,13 @@ 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, + ccsArchiveDir, + dryRun, + force, + }); } } @@ -319,40 +333,48 @@ 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; + ccsArchiveDir: string; + dryRun: boolean; + force: boolean; +}): Promise { + const targets = [ + { label: 'CCS Logs', dir: options.ccsLogsDir }, + { label: 'CCS Log Archives', dir: options.ccsArchiveDir }, + { 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 +393,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/command-catalog.ts b/src/commands/command-catalog.ts index a6d1bf59..3b14c3b7 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -137,7 +137,7 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [ }, { name: 'cleanup', - summary: 'Remove old CLIProxy logs', + summary: 'Remove old CCS and CLIProxy logs', group: 'operations', aliases: ['--cleanup'], visibility: 'public', 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..258e256d --- /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, mode: 0o700 }); + fs.mkdirSync(getLogArchiveDir(), { recursive: true, mode: 0o700 }); +} + +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..7d8ef1b9 --- /dev/null +++ b/src/services/logging/log-reader.ts @@ -0,0 +1,126 @@ +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'; + +type CurrentLogCache = { + entries: LogEntry[]; + mtimeNs: bigint; + path: string; + size: bigint; +} | null; + +let currentLogCache: CurrentLogCache = null; + +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)) { + currentLogCache = null; + return []; + } + + const stats = fs.statSync(currentLogPath, { bigint: true }); + if ( + currentLogCache && + currentLogCache.path === currentLogPath && + currentLogCache.mtimeNs === stats.mtimeNs && + currentLogCache.size === stats.size + ) { + return [...currentLogCache.entries]; + } + + const entries = fs + .readFileSync(currentLogPath, 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map(parseLogLine) + .filter((entry): entry is LogEntry => entry !== null); + + currentLogCache = { + entries, + mtimeNs: stats.mtimeNs, + path: currentLogPath, + size: stats.size, + }; + + return [...entries]; +} + +function matchesLogQuery(entry: LogEntry, options: ReadLogEntriesOptions): boolean { + if (options.source && entry.source !== options.source) { + return false; + } + + if (options.level && entry.level !== options.level) { + return false; + } + + 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) + ); +} + +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 limit = options.limit ?? 200; + const entries = dedupeEntries([...readCurrentFileEntries(), ...getRecentLogEntries()]) + .filter((entry) => matchesLogQuery(entry, options)) + .sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); + + return entries.slice(0, limit); +} + +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/commands/cleanup-command.test.ts b/tests/unit/commands/cleanup-command.test.ts new file mode 100644 index 00000000..1c560762 --- /dev/null +++ b/tests/unit/commands/cleanup-command.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { handleCleanupCommand } from '../../../src/commands/cleanup-command'; +import { getCliproxyDir } from '../../../src/cliproxy/config-generator'; +import { getLogArchiveDir, getNativeLogsDir } from '../../../src/services/logging'; + +describe('cleanup command', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cleanup-command-')); + 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('reports CCS archives alongside current logs in dry-run mode', async () => { + const ccsLogsDir = getNativeLogsDir(); + const archiveDir = getLogArchiveDir(); + const cliproxyLogsDir = path.join(getCliproxyDir(), 'logs'); + + fs.mkdirSync(archiveDir, { recursive: true }); + fs.mkdirSync(cliproxyLogsDir, { recursive: true }); + fs.writeFileSync(path.join(ccsLogsDir, 'current.jsonl'), 'x'.repeat(100)); + fs.writeFileSync(path.join(archiveDir, 'archived.jsonl.gz'), 'y'.repeat(2_000)); + + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + try { + await handleCleanupCommand(['--dry-run']); + + const output = logSpy.mock.calls + .flatMap((call) => call.map((value) => String(value))) + .join('\n'); + + expect(output).toContain('CCS Logs: 1 files (100.00 B)'); + expect(output).toContain('CCS Log Archives: 1 files (1.95 KB)'); + expect(output).toContain('Would delete 2 files (2.05 KB)'); + } finally { + logSpy.mockRestore(); + } + }); +}); diff --git a/tests/unit/commands/command-catalog.test.ts b/tests/unit/commands/command-catalog.test.ts index 4e8b0b31..ea0b8f4b 100644 --- a/tests/unit/commands/command-catalog.test.ts +++ b/tests/unit/commands/command-catalog.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import { ROOT_COMMAND_CATALOG, getAllRootCommandTokens } from '../../../src/commands/command-catalog'; +import { + ROOT_COMMAND_CATALOG, + getAllRootCommandTokens, +} from '../../../src/commands/command-catalog'; import { ROOT_COMMAND_ROUTES } from '../../../src/commands/root-command-router'; describe('command catalog', () => { @@ -16,12 +19,18 @@ describe('command catalog', () => { }); test('keeps hidden operational hooks out of the public help surface', () => { - const hiddenCommands = ROOT_COMMAND_CATALOG.filter((entry) => entry.visibility === 'hidden').map( - (entry) => entry.name - ); + const hiddenCommands = ROOT_COMMAND_CATALOG.filter( + (entry) => entry.visibility === 'hidden' + ).map((entry) => entry.name); expect(hiddenCommands).toContain('--install'); expect(hiddenCommands).toContain('--uninstall'); expect(hiddenCommands).toContain('__complete'); }); + + test('describes cleanup as removing both CCS and CLIProxy logs', () => { + const cleanupCommand = ROOT_COMMAND_CATALOG.find((entry) => entry.name === 'cleanup'); + + expect(cleanupCommand?.summary).toBe('Remove old CCS and CLIProxy logs'); + }); }); 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..41ef7a84 --- /dev/null +++ b/tests/unit/services/logging/log-paths.test.ts @@ -0,0 +1,57 @@ +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 { + ensureLoggingDirectories, + 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 + ); + }); + + it('creates log directories with restrictive permissions', () => { + ensureLoggingDirectories(); + + const logsMode = fs.statSync(getNativeLogsDir()).mode & 0o777; + const archiveMode = fs.statSync(getLogArchiveDir()).mode & 0o777; + + expect(logsMode).toBe(0o700); + expect(archiveMode).toBe(0o700); + }); +}); diff --git a/tests/unit/services/logging/log-reader.test.ts b/tests/unit/services/logging/log-reader.test.ts new file mode 100644 index 00000000..f7b1523d --- /dev/null +++ b/tests/unit/services/logging/log-reader.test.ts @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + clearRecentLogEntries, + pushRecentLogEntry, +} from '../../../../src/services/logging/log-buffer'; +import { getCurrentLogPath } from '../../../../src/services/logging/log-paths'; +import { readLogEntries } from '../../../../src/services/logging/log-reader'; +import type { LogEntry } from '../../../../src/services/logging/log-types'; + +function createEntry(overrides: Partial): LogEntry { + return { + id: overrides.id ?? `entry-${Math.random().toString(36).slice(2, 8)}`, + timestamp: overrides.timestamp ?? new Date().toISOString(), + level: overrides.level ?? 'info', + source: overrides.source ?? 'unit:test', + event: overrides.event ?? 'test.event', + message: overrides.message ?? 'message', + processId: overrides.processId ?? 1234, + runId: overrides.runId ?? 'run-1', + context: overrides.context ?? {}, + }; +} + +describe('log reader', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-log-reader-')); + process.env.CCS_HOME = tempHome; + clearRecentLogEntries(); + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + clearRecentLogEntries(); + fs.rmSync(tempHome, { recursive: true, force: true }); + tempHome = ''; + }); + + it('caches unchanged current log file parses between reads', () => { + const currentLogPath = getCurrentLogPath(); + fs.mkdirSync(path.dirname(currentLogPath), { recursive: true }); + fs.writeFileSync( + currentLogPath, + `${JSON.stringify( + createEntry({ + id: 'disk-entry', + message: 'Newest on-disk entry', + timestamp: '2026-04-08T11:00:00.000Z', + }) + )}\n` + ); + + pushRecentLogEntry( + createEntry({ + id: 'recent-1', + message: 'Buffered entry', + timestamp: '2026-04-08T10:00:00.000Z', + }), + 250 + ); + + const readSpy = spyOn(fs, 'readFileSync'); + + try { + const first = readLogEntries({ limit: 2 }); + const second = readLogEntries({ limit: 2 }); + + expect(first.map((entry) => entry.id)).toEqual(['disk-entry', 'recent-1']); + expect(second.map((entry) => entry.id)).toEqual(['disk-entry', 'recent-1']); + expect(readSpy).toHaveBeenCalledTimes(1); + } finally { + readSpy.mockRestore(); + } + }); + + it('refreshes the cached parse when the current log file changes', () => { + const currentLogPath = getCurrentLogPath(); + fs.mkdirSync(path.dirname(currentLogPath), { recursive: true }); + fs.writeFileSync( + currentLogPath, + `${JSON.stringify( + createEntry({ + id: 'disk-old', + message: 'Older on-disk entry', + timestamp: '2026-04-08T11:00:00.000Z', + }) + )}\n` + ); + + const readSpy = spyOn(fs, 'readFileSync'); + + try { + expect(readLogEntries({ limit: 1 }).map((entry) => entry.id)).toEqual(['disk-old']); + + fs.writeFileSync( + currentLogPath, + `${JSON.stringify( + createEntry({ + id: 'disk-new', + message: 'Newer on-disk entry', + timestamp: '2026-04-08T12:00:00.000Z', + }) + )}\n` + ); + const futureTimestamp = new Date(Date.now() + 10_000); + fs.utimesSync(currentLogPath, futureTimestamp, futureTimestamp); + + expect(readLogEntries({ limit: 1 }).map((entry) => entry.id)).toEqual(['disk-new']); + expect(readSpy).toHaveBeenCalledTimes(2); + } finally { + readSpy.mockRestore(); + } + }); + + it('keeps file-backed matches when buffered entries already satisfy the limit', () => { + const currentLogPath = getCurrentLogPath(); + fs.mkdirSync(path.dirname(currentLogPath), { recursive: true }); + fs.writeFileSync( + currentLogPath, + [ + JSON.stringify( + createEntry({ + id: 'disk-newest', + source: 'dashboard', + message: 'Newest dashboard entry on disk', + timestamp: '2026-04-08T12:30:00.000Z', + }) + ), + JSON.stringify( + createEntry({ + id: 'disk-older', + source: 'dashboard', + message: 'Older dashboard entry on disk', + timestamp: '2026-04-08T10:30:00.000Z', + }) + ), + ].join('\n') + '\n' + ); + + pushRecentLogEntry( + createEntry({ + id: 'recent-middle', + source: 'dashboard', + message: 'Buffered dashboard entry', + timestamp: '2026-04-08T11:30:00.000Z', + }), + 250 + ); + pushRecentLogEntry( + createEntry({ + id: 'recent-oldest', + source: 'dashboard', + message: 'Oldest buffered dashboard entry', + timestamp: '2026-04-08T09:30:00.000Z', + }), + 250 + ); + + const entries = readLogEntries({ source: 'dashboard', limit: 2 }); + + expect(entries.map((entry) => entry.id)).toEqual(['disk-newest', 'recent-middle']); + }); +}); diff --git a/tests/unit/services/logging/log-redaction.test.ts b/tests/unit/services/logging/log-redaction.test.ts new file mode 100644 index 00000000..124165eb --- /dev/null +++ b/tests/unit/services/logging/log-redaction.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'bun:test'; +import { redactContext } from '../../../../src/services/logging/log-redaction'; + +describe('log redaction', () => { + it('redacts sensitive keys and preserves non-sensitive values', () => { + const redacted = redactContext({ + token: 'secret-token', + api_key: 'secret-key', + safe: 'kept', + count: 3, + enabled: true, + }); + + expect(redacted).toEqual({ + token: '[redacted]', + api_key: '[redacted]', + safe: 'kept', + count: 3, + enabled: true, + }); + }); + + it('sanitizes nested objects and arrays recursively', () => { + const redacted = redactContext({ + request: { + headers: { + authorization: 'Bearer abc', + cookie: 'session=123', + }, + steps: [ + { secret: 'hidden' }, + { label: 'safe-step' }, + ['nested-array', { password_hash: 'hidden-hash' }], + ], + }, + }); + + expect(redacted).toEqual({ + request: { + headers: { + authorization: '[redacted]', + cookie: '[redacted]', + }, + steps: [ + { secret: '[redacted]' }, + { label: 'safe-step' }, + ['nested-array', { password_hash: '[redacted]' }], + ], + }, + }); + }); + + it('caps recursive depth, truncates long strings, and preserves nullish values', () => { + const deeplyNested = { + first: { + second: { + third: { + fourth: { + fifth: { + sixth: 'too-deep', + }, + }, + }, + }, + }, + }; + const longValue = 'a'.repeat(2_500); + + const redacted = redactContext({ + nested: deeplyNested, + longValue, + nothing: null, + missing: undefined, + }); + + expect(redacted.nested).toEqual({ + first: { + second: { + third: { + fourth: '[max-depth]', + }, + }, + }, + }); + expect(redacted.longValue).toBe(`${'a'.repeat(2_000)}...[truncated]`); + expect(redacted.nothing).toBeNull(); + expect(redacted.missing).toBeUndefined(); + }); + + it('reduces Error instances to safe name and message fields', () => { + const error = new Error('boom'.repeat(700)); + error.name = 'ExplodedError'; + + const redacted = redactContext({ error }); + + expect(redacted).toEqual({ + error: { + name: 'ExplodedError', + message: `${'boom'.repeat(500)}...[truncated]`, + }, + }); + }); +}); diff --git a/tests/unit/services/logging/log-storage.test.ts b/tests/unit/services/logging/log-storage.test.ts new file mode 100644 index 00000000..23decb23 --- /dev/null +++ b/tests/unit/services/logging/log-storage.test.ts @@ -0,0 +1,118 @@ +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 * as zlib from 'zlib'; +import { createEmptyUnifiedConfig } from '../../../../src/config/unified-config-types'; +import { saveUnifiedConfig } from '../../../../src/config/unified-config-loader'; +import { clearRecentLogEntries } from '../../../../src/services/logging/log-buffer'; +import { invalidateLoggingConfigCache } from '../../../../src/services/logging/log-config'; +import { getCurrentLogPath, getLogArchiveDir } from '../../../../src/services/logging/log-paths'; +import { + appendStructuredLogEntry, + pruneExpiredLogArchives, +} from '../../../../src/services/logging/log-storage'; +import type { LogEntry } from '../../../../src/services/logging/log-types'; + +function createEntry(overrides: Partial): LogEntry { + return { + id: overrides.id ?? 'entry-1', + timestamp: overrides.timestamp ?? new Date().toISOString(), + level: overrides.level ?? 'info', + source: overrides.source ?? 'unit:test', + event: overrides.event ?? 'test.event', + message: overrides.message ?? 'message', + processId: overrides.processId ?? 1234, + runId: overrides.runId ?? 'run-1', + context: overrides.context ?? {}, + }; +} + +describe('log storage', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-log-storage-')); + process.env.CCS_HOME = tempHome; + clearRecentLogEntries(); + invalidateLoggingConfigCache(); + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + clearRecentLogEntries(); + invalidateLoggingConfigCache(); + fs.rmSync(tempHome, { recursive: true, force: true }); + tempHome = ''; + }); + + it('rotates the current log into the archive when the file exceeds the age threshold', () => { + const config = createEmptyUnifiedConfig(); + config.logging.retain_days = 7; + config.logging.rotate_mb = 10; + saveUnifiedConfig(config); + invalidateLoggingConfigCache(); + + const currentLogPath = getCurrentLogPath(); + fs.mkdirSync(path.dirname(currentLogPath), { recursive: true }); + fs.writeFileSync( + currentLogPath, + `${JSON.stringify(createEntry({ id: 'old-entry' }))}\n`, + 'utf8' + ); + const staleTimestamp = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + fs.utimesSync(currentLogPath, staleTimestamp, staleTimestamp); + + appendStructuredLogEntry( + createEntry({ + id: 'new-entry', + message: 'new log entry after rotation', + }) + ); + + const archiveDir = getLogArchiveDir(); + const archives = fs.readdirSync(archiveDir); + expect(archives).toHaveLength(1); + + const archivedContent = zlib + .gunzipSync(fs.readFileSync(path.join(archiveDir, archives[0]))) + .toString('utf8'); + expect(archivedContent).toContain('"id":"old-entry"'); + + const currentContent = fs.readFileSync(currentLogPath, 'utf8'); + expect(currentContent).toContain('"id":"new-entry"'); + expect(currentContent).not.toContain('"id":"old-entry"'); + }); + + it('prunes expired archives according to retention settings', () => { + const config = createEmptyUnifiedConfig(); + config.logging.retain_days = 1; + saveUnifiedConfig(config); + invalidateLoggingConfigCache(); + + const archiveDir = getLogArchiveDir(); + fs.mkdirSync(archiveDir, { recursive: true }); + + const oldArchive = path.join(archiveDir, 'ccs-old.jsonl.gz'); + const freshArchive = path.join(archiveDir, 'ccs-fresh.jsonl.gz'); + fs.writeFileSync(oldArchive, zlib.gzipSync('old archive'), { mode: 0o600 }); + fs.writeFileSync(freshArchive, zlib.gzipSync('fresh archive'), { mode: 0o600 }); + + const oldTimestamp = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000); + const freshTimestamp = new Date(Date.now() - 2 * 60 * 60 * 1000); + fs.utimesSync(oldArchive, oldTimestamp, oldTimestamp); + fs.utimesSync(freshArchive, freshTimestamp, freshTimestamp); + + pruneExpiredLogArchives(); + + expect(fs.existsSync(oldArchive)).toBe(false); + expect(fs.existsSync(freshArchive)).toBe(true); + }); +}); 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/.gitignore b/ui/.gitignore index a547bf36..2005c0ca 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -1,5 +1,7 @@ # Logs logs +!src/components/logs/ +!src/components/logs/** *.log npm-debug.log* yarn-debug.log* 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/components/logs/log-level-badge.tsx b/ui/src/components/logs/log-level-badge.tsx new file mode 100644 index 00000000..e4de4bee --- /dev/null +++ b/ui/src/components/logs/log-level-badge.tsx @@ -0,0 +1,24 @@ +import type { LogsLevel } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; +import { getLevelLabel } from './utils'; + +const LEVEL_STYLES: Record = { + error: 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300', + warn: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300', + info: 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300', + debug: 'border-zinc-500/30 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300', +}; + +export function LogLevelBadge({ level, className }: { level: LogsLevel; className?: string }) { + return ( + + {getLevelLabel(level)} + + ); +} diff --git a/ui/src/components/logs/logs-config-card.tsx b/ui/src/components/logs/logs-config-card.tsx new file mode 100644 index 00000000..4fe573a3 --- /dev/null +++ b/ui/src/components/logs/logs-config-card.tsx @@ -0,0 +1,282 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Save, Settings2, ShieldAlert, RotateCcw, Activity } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import type { LogsConfig, UpdateLogsConfigPayload } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; + +function parseInteger(value: string, fallback: number) { + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed)) { + return fallback; + } + + return Math.max(0, parsed); +} + +export function LogsConfigCard({ + config, + onSave, + isPending, +}: { + config: LogsConfig; + onSave: (payload: UpdateLogsConfigPayload) => void; + isPending: boolean; +}) { + const [draft, setDraft] = useState(config); + + useEffect(() => { + setDraft(config); + }, [config]); + + const isDirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(config), [config, draft]); + + return ( +
+
+
+
+ +
+
+

+ Logging Policy +

+

+ Retention and privacy +

+
+
+
+
+ +
+
+
+ + Active Status + +
+ + {config.enabled ? 'Live' : 'Off'} + +
+
+
+ + Redaction + + + {config.redact ? 'Enforced' : 'Plain'} + +
+
+ +
+
+
+ +

+ Enable structured logging +

+
+ + setDraft((current) => ({ ...current, enabled: checked })) + } + className="data-[state=checked]:bg-primary" + /> +
+ +
+
+ +

+ Sanitize payload data +

+
+ + setDraft((current) => ({ ...current, redact: checked })) + } + className="data-[state=checked]:bg-primary" + /> +
+
+ +
+
+
+ + +
+ +
+ +
+
+ + + setDraft((current) => ({ + ...current, + rotate_mb: parseInteger(event.target.value, current.rotate_mb), + })) + } + /> +
+
+ + + setDraft((current) => ({ + ...current, + retain_days: parseInteger(event.target.value, current.retain_days), + })) + } + /> +
+
+
+ +
+ + +
+
+ +
+
+ + + Operational Logic v3.4 + +
+ {isDirty && ( +
+
+ + Pending + +
+ )} +
+
+ ); +} diff --git a/ui/src/components/logs/logs-detail-panel.tsx b/ui/src/components/logs/logs-detail-panel.tsx new file mode 100644 index 00000000..f2b219c5 --- /dev/null +++ b/ui/src/components/logs/logs-detail-panel.tsx @@ -0,0 +1,266 @@ +import { + FileJson, + Info, + ShieldCheck, + Terminal, + Fingerprint, + Database, + Cpu, + Activity, + type LucideIcon, +} from 'lucide-react'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import type { LogsEntry } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; +import { LogLevelBadge } from './log-level-badge'; +import { formatJson } from './utils'; + +function MetaRow({ + label, + value, + icon: Icon, +}: { + label: string; + value: string | number; + icon?: LucideIcon; +}) { + return ( +
+
+
+ {Icon && ( + + )} +

+ {label} +

+
+
+
+

+ {value} +

+
+ ); +} + +export function LogsDetailPanel({ + entry, + sourceLabel, +}: { + entry: LogsEntry | null; + sourceLabel?: string; +}) { + if (!entry) { + return ( +
+
+
+
+ +
+
+
+

+ Inspector Standby +

+

+ Select a telemetry node from the active data queue to perform deep analysis of its + operational context. +

+
+
+ ); + } + + return ( +
+ {/* Tactical Inspector Header */} +
+ {/* Pattern Overlay */} +
+ +
+
+
+ +
+
+ + + {sourceLabel ?? entry.source} + +
+
+
+ + + {new Date(entry.timestamp).toLocaleTimeString(undefined, { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} + +
+
+ +
+
+
+

+ Event +

+
+

+ {entry.event} +

+
+

+ {entry.message} +

+
+
+
+
+ + +
+ + + + + Details + + + + Raw Context + + + + +
+ + + + +
+ +
+ {/* Background Scanline */} +
+ +
+
+
+ +
+
+

+ Automated Summary +

+

+ Quick interpretation +

+
+
+

+ This telemetry node was captured from{' '} + + {sourceLabel ?? entry.source} + + operating at the{' '} + + {entry.level} + {' '} + threshold. The operational payload indicates an event state of{' '} + {entry.event}. +

+
+
+ + + +
+ {/* Copy HUD */} +
+
+ JSON.RAW.MODE +
+
+ + +
+                    {formatJson({
+                      id: entry.id,
+                      timestamp: entry.timestamp,
+                      level: entry.level,
+                      source: entry.source,
+                      event: entry.event,
+                      message: entry.message,
+                      processId: entry.processId,
+                      runId: entry.runId,
+                      context: entry.context ?? {},
+                    })}
+                  
+
+
+
+ +
+ + +
+
+
+
+ + Node Verified + +
+
+ + {entry.id.slice(0, 8)} + +
+
+ + CCS-TEC-v3 + +
+
+
+ ); +} diff --git a/ui/src/components/logs/logs-entry-list.tsx b/ui/src/components/logs/logs-entry-list.tsx new file mode 100644 index 00000000..dc205f6a --- /dev/null +++ b/ui/src/components/logs/logs-entry-list.tsx @@ -0,0 +1,215 @@ +import { Activity, ArrowRight, Inbox, Loader2 } from 'lucide-react'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import type { LogsEntry } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; +import { LogLevelBadge } from './log-level-badge'; + +export function LogsEntryList({ + entries, + selectedEntryId, + onSelect, + sourceLabels, + isLoading, + isFetching, +}: { + entries: LogsEntry[]; + selectedEntryId: string | null; + onSelect: (entryId: string) => void; + sourceLabels: Record; + isLoading: boolean; + isFetching: boolean; +}) { + return ( +
+
+
+
+
+

+ Live Entry Stream +

+
+
+
+ + + Live telemetry + +
+
+
+ {isFetching && ( +
+ + + Syncing + +
+ )} + + NODE.01 + +
+
+ +
+
Time
+
Lvl
+
Source
+
Message
+
Proc
+
Run
+
Open
+
+ +
+ {isLoading ? ( +
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].map((item) => ( +
+ ))} +
+ ) : entries.length === 0 ? ( +
+
+
+
+ +
+
+
+

+ No matching entries +

+

+ Your current source, level, or search filters are hiding the stream. Adjust them to + bring entries back into view. +

+
+
+ ) : ( + +
+ {entries.map((entry) => { + const isSelected = entry.id === selectedEntryId; + + return ( + + ); + })} +
+
+ )} +
+ +
+
+ + Node: CCS-CORE + +
+ + Status: Operational + +
+
+ + Entries: {entries.length} + +
+
+
+ ); +} diff --git a/ui/src/components/logs/logs-filters.tsx b/ui/src/components/logs/logs-filters.tsx new file mode 100644 index 00000000..f212890d --- /dev/null +++ b/ui/src/components/logs/logs-filters.tsx @@ -0,0 +1,198 @@ +import { Search, RefreshCw, Filter, Shield, Zap } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import type { LogsSource } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; +import type { LogsLevelFilter, LogsSourceFilter } from '@/hooks/use-logs'; +import { getLogLevelOptions } from '@/hooks/use-logs'; + +export function LogsFilters({ + sources, + selectedSource, + onSourceChange, + selectedLevel, + onLevelChange, + search, + onSearchChange, + limit, + onLimitChange, + onRefresh, + isRefreshing, +}: { + sources: LogsSource[]; + selectedSource: LogsSourceFilter; + onSourceChange: (value: LogsSourceFilter) => void; + selectedLevel: LogsLevelFilter; + onLevelChange: (value: LogsLevelFilter) => void; + search: string; + onSearchChange: (value: string) => void; + limit: number; + onLimitChange: (value: number) => void; + onRefresh: () => void; + isRefreshing: boolean; +}) { + const levels = getLogLevelOptions(); + const limits = [50, 100, 150, 250]; + + return ( +
+
+
+
+
+ +
+ +
+
+
+ +
+ onSearchChange(event.target.value)} + placeholder="Scan for patterns..." + className="h-11 rounded-xl border-2 border-border/40 bg-background/50 pl-10 text-[13px] font-medium text-foreground placeholder:text-foreground/35 focus-visible:border-primary/40 focus-visible:ring-0 transition-all shadow-inner" + /> +
+
+ +
+
+ + +
+
+ + +
+ {sources.map((source) => ( + + ))} +
+
+
+ + {/* Threshold Control */} +
+
+ + +
+
+ {levels.map((option) => ( + + ))} +
+
+ + {/* Operational Deck */} +
+
+
+
+

+ Operational Window +

+

+ Tail Capacity +

+
+
+ +
+
+ +
+ {limits.map((option) => ( + + ))} +
+ + +
+
+
+ ); +} diff --git a/ui/src/components/logs/logs-overview-cards.tsx b/ui/src/components/logs/logs-overview-cards.tsx new file mode 100644 index 00000000..678f8548 --- /dev/null +++ b/ui/src/components/logs/logs-overview-cards.tsx @@ -0,0 +1,90 @@ +import { Activity, Archive, Database, RadioTower } from 'lucide-react'; +import type { LogsConfig, LogsEntry, LogsSource } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; +import { formatCount, formatLogTimestamp, formatRelativeLogTime } from './utils'; + +function MetricCard({ + label, + value, + detail, + icon: Icon, + accent, +}: { + label: string; + value: string; + detail: string; + icon: typeof Activity; + accent: string; +}) { + return ( +
+
+
+

{label}

+

{value}

+

{detail}

+
+
+ +
+
+
+ ); +} + +export function LogsOverviewCards({ + config, + sources, + entries, + latestTimestamp, +}: { + config: LogsConfig; + sources: LogsSource[]; + entries: LogsEntry[]; + latestTimestamp: string | null; +}) { + const nativeSources = sources.filter((source) => source.kind === 'native').length; + const legacySources = sources.length - nativeSources; + const errorCount = entries.filter((entry) => entry.level === 'error').length; + + return ( +
+ + + 0 ? ` • ${legacySources} legacy` : ''}`} + icon={Database} + accent="bg-sky-500/10 text-sky-700 dark:text-sky-300" + /> + +
+ Last ingested event:{' '} + {formatLogTimestamp(latestTimestamp)} +
+
+ ); +} diff --git a/ui/src/components/logs/logs-page-skeleton.tsx b/ui/src/components/logs/logs-page-skeleton.tsx new file mode 100644 index 00000000..bd31baf6 --- /dev/null +++ b/ui/src/components/logs/logs-page-skeleton.tsx @@ -0,0 +1,56 @@ +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; + +export function LogsPageSkeleton() { + return ( +
+ + + + + + + + +
+ {[1, 2, 3, 4].map((item) => ( + + + + + + + ))} +
+ +
+ + + +
+ {[1, 2, 3, 4].map((item) => ( + + ))} +
+
+ + + + +
+ + + + + + + + {[1, 2, 3, 4].map((item) => ( + + ))} + + +
+
+ ); +} diff --git a/ui/src/components/logs/utils.ts b/ui/src/components/logs/utils.ts new file mode 100644 index 00000000..16d0cff8 --- /dev/null +++ b/ui/src/components/logs/utils.ts @@ -0,0 +1,77 @@ +import type { LogsLevel } from '@/lib/api-client'; + +export function formatLogTimestamp(timestamp: string | null | undefined) { + if (!timestamp) { + return 'No activity yet'; + } + + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return timestamp; + } + + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date); +} + +export function formatRelativeLogTime(timestamp: string | null | undefined) { + if (!timestamp) { + return 'No activity yet'; + } + + const value = new Date(timestamp).getTime(); + if (Number.isNaN(value)) { + return timestamp; + } + + const diffSeconds = Math.round((value - Date.now()) / 1000); + const absSeconds = Math.abs(diffSeconds); + const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); + + if (absSeconds < 60) { + return formatter.format(diffSeconds, 'second'); + } + + const diffMinutes = Math.round(diffSeconds / 60); + if (Math.abs(diffMinutes) < 60) { + return formatter.format(diffMinutes, 'minute'); + } + + const diffHours = Math.round(diffMinutes / 60); + if (Math.abs(diffHours) < 24) { + return formatter.format(diffHours, 'hour'); + } + + return formatter.format(Math.round(diffHours / 24), 'day'); +} + +export function formatCount(value: number) { + return new Intl.NumberFormat().format(value); +} + +export function formatJson(value: unknown) { + if (value === null || value === undefined) { + return '{}'; + } + + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +export function getLevelLabel(level: LogsLevel) { + switch (level) { + case 'error': + return 'Error'; + case 'warn': + return 'Warn'; + case 'info': + return 'Info'; + case 'debug': + return 'Debug'; + } +} 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/index.css b/ui/src/index.css index b15fdb8e..88723524 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -374,3 +374,50 @@ .scrollbar-editor::-webkit-scrollbar-corner { background: transparent; } + +@keyframes scan { + 0% { + transform: translateY(0); + } + 100% { + transform: translateY(100%); + } +} + +.animate-scan { + animation: scan 8s linear infinite; +} + +@keyframes slide-in-from-right { + from { + transform: translateX(10px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slide-in-from-left { + from { + transform: translateX(-10px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.slide-in-from-right-4 { + animation: slide-in-from-right 0.5s ease-out; +} + +.slide-in-from-left-2 { + animation: slide-in-from-left 0.4s ease-out; +} + +.slide-in-from-bottom-2 { + animation: enter 0.3s ease-out; +} 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..e96d1ab9 --- /dev/null +++ b/ui/src/pages/logs.tsx @@ -0,0 +1,484 @@ +import { useEffect, useState } from 'react'; +import { + ArrowRight, + ChevronLeft, + ChevronRight, + RefreshCw, + ScrollText, + TimerReset, +} from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { cn } from '@/lib/utils'; +import { ErrorLogsMonitor } from '@/components/error-logs-monitor'; +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 { LogsPageSkeleton } from '@/components/logs/logs-page-skeleton'; +import { getSourceLabelMap, useLogsWorkspace, useUpdateLogsConfig } from '@/hooks/use-logs'; + +const DESKTOP_LOGS_BREAKPOINT = 1200; +const LEFT_PANEL_WIDTH = 336; +const RIGHT_PANEL_WIDTH = 368; +const COLLAPSED_PANEL_WIDTH = 52; + +function CollapsedPaneToggle({ + side, + label, + onExpand, +}: { + side: 'left' | 'right'; + label: string; + onExpand: () => void; +}) { + return ( +
+ + + {label} + +
+ ); +} + +export function LogsPage() { + const workspace = useLogsWorkspace(); + const updateConfig = useUpdateLogsConfig(); + const sourceLabels = getSourceLabelMap(workspace.sourcesQuery.data ?? []); + const [isDesktopLayout, setIsDesktopLayout] = useState(() => + typeof window !== 'undefined' ? window.innerWidth >= DESKTOP_LOGS_BREAKPOINT : false + ); + const [isFiltersCollapsed, setIsFiltersCollapsed] = useState(false); + const [isDetailsCollapsed, setIsDetailsCollapsed] = useState(false); + + useEffect(() => { + const mediaQuery = window.matchMedia(`(min-width: ${DESKTOP_LOGS_BREAKPOINT}px)`); + const syncLayout = () => { + setIsDesktopLayout(window.innerWidth >= DESKTOP_LOGS_BREAKPOINT); + }; + + syncLayout(); + mediaQuery.addEventListener('change', syncLayout); + return () => mediaQuery.removeEventListener('change', syncLayout); + }, []); + + if (workspace.isInitialLoading) { + return ; + } + + const config = workspace.configQuery.data; + if (!config) { + return null; + } + + return ( +
+
+ +
+
+
+
+ +
+
+
+ + Operational + +

+ Log Operations Center +

+
+

+ CCS.TOC.LOGS.STREAM.v3 +

+
+
+ +
+ +
+
+
+
+ + Redaction + + + {config.redact ? 'Enforced' : 'Standby'} + +
+
+
+
+ +
+
+ + Retention + + + {config.retain_days}D / {config.rotate_mb}MB + +
+
+
+
+ +
+ +
+ +
+
+ +
+ +
+ + + Telemetry Stream + + + Legacy Errors + + + +
+
+ + + + + + Connected + +
+ + {workspace.entriesQuery.data?.length ?? 0} captured + +
+
+ + + {isDesktopLayout ? ( +
+
+ {isFiltersCollapsed ? ( + setIsFiltersCollapsed(false)} + /> + ) : ( +
+
+
+

+ Filters +

+

+ Search, source, and retention controls +

+
+ +
+ + +
+ + void Promise.all([ + workspace.sourcesQuery.refetch(), + workspace.entriesQuery.refetch(), + ]) + } + isRefreshing={ + workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching + } + /> +
+ updateConfig.mutate(payload)} + isPending={updateConfig.isPending} + /> +
+
+
+
+ )} +
+ +
+ +
+ +
+ {isDetailsCollapsed ? ( + setIsDetailsCollapsed(false)} + /> + ) : ( +
+
+
+

+ Details +

+

+ Selected entry context and raw payload +

+
+ +
+
+ +
+
+ )} +
+
+ ) : ( +
+
+
+ + void Promise.all([ + workspace.sourcesQuery.refetch(), + workspace.entriesQuery.refetch(), + ]) + } + isRefreshing={ + workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching + } + /> + updateConfig.mutate(payload)} + isPending={updateConfig.isPending} + /> +
+
+ +
+ +
+ +
+ +
+
+ )} +
+ + +
+
+
+ + + +
+
+
+ +
+
+

+ Legacy Diagnostic Node +

+

+ CCS-MATRIX-FAILURE-MONITOR +

+
+
+
+ Mode: Historical +
+
+ +
+

+ CLIProxy Failure Analysis +

+

+ Maintain oversight of legacy request failures while the unified stream + consolidates system-wide telemetry. This view provides direct access to the + historical failure matrix for deep-field debugging. +

+
+ +
+ + +
+ +
+
+
+ + Realtime Monitoring Deck + +
+ +
+
+ + +
+
+ ); +} 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..32521ffa --- /dev/null +++ b/ui/tests/unit/ui/pages/logs-page.test.tsx @@ -0,0 +1,199 @@ +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: 4121, + 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: 25582, + 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: 4121, + 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; + Object.defineProperty(window, 'innerWidth', { + configurable: true, + writable: true, + value: 900, + }); + }); + + 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('button', { 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.getAllByText('4121').length).toBeGreaterThan(0); + + await userEvent.click(screen.getByRole('tab', { name: /Raw context/i })); + + expect(await screen.findByText(/network jitter/)).toBeInTheDocument(); + expect(screen.getByText(/run-2/)).toBeInTheDocument(); + }); +});