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

Logs moved to a dedicated workspace

+

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

+
+
+ +
+
+ ); diff --git a/ui/src/pages/logs.tsx b/ui/src/pages/logs.tsx new file mode 100644 index 00000000..943a89f0 --- /dev/null +++ b/ui/src/pages/logs.tsx @@ -0,0 +1,130 @@ +import { AlertCircle, ArrowRight, RefreshCw } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { LogsConfigCard } from '@/components/logs/logs-config-card'; +import { LogsDetailPanel } from '@/components/logs/logs-detail-panel'; +import { LogsEntryList } from '@/components/logs/logs-entry-list'; +import { LogsFilters } from '@/components/logs/logs-filters'; +import { LogsOverviewCards } from '@/components/logs/logs-overview-cards'; +import { LogsPageSkeleton } from '@/components/logs/logs-page-skeleton'; +import { getSourceLabelMap, useLogsWorkspace, useUpdateLogsConfig } from '@/hooks/use-logs'; + +export function LogsPage() { + const workspace = useLogsWorkspace(); + const updateConfig = useUpdateLogsConfig(); + const sourceLabels = getSourceLabelMap(workspace.sourcesQuery.data ?? []); + const errors = [ + workspace.configQuery.error, + workspace.sourcesQuery.error, + workspace.entriesQuery.error, + ].filter(Boolean) as Error[]; + + if (workspace.isInitialLoading) { + return ( +
+ +
+ ); + } + + const config = workspace.configQuery.data; + if (!config) { + return null; + } + + return ( +
+
+
+
+

System logs

+
+

Unified CCS logging

+

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

+
+
+
+ + +
+
+
+ + {errors.length > 0 ? ( + + + Unable to fully load the logs workspace + {errors[0]?.message} + + ) : null} + + + +
+
+ + void Promise.all([workspace.sourcesQuery.refetch(), workspace.entriesQuery.refetch()]) + } + isRefreshing={workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching} + /> + +
+ + +
+
+ + updateConfig.mutate(payload)} + isPending={updateConfig.isPending} + /> +
+
+ ); +} diff --git a/ui/tests/unit/ui/pages/logs-page.test.tsx b/ui/tests/unit/ui/pages/logs-page.test.tsx new file mode 100644 index 00000000..d4227e8c --- /dev/null +++ b/ui/tests/unit/ui/pages/logs-page.test.tsx @@ -0,0 +1,195 @@ +import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { LogsPage } from '@/pages/logs'; + +const fetchMock = vi.fn(); + +beforeAll(() => { + if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false; + } + + if (!Element.prototype.setPointerCapture) { + Element.prototype.setPointerCapture = () => undefined; + } + + if (!Element.prototype.releasePointerCapture) { + Element.prototype.releasePointerCapture = () => undefined; + } + + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => undefined; + } +}); + +function jsonResponse(body: unknown) { + return Promise.resolve( + new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); +} + +function buildEntries(source: string) { + if (source === 'agent-runner') { + return [ + { + id: 'entry-2', + timestamp: '2026-04-07T11:10:00.000Z', + level: 'warn', + source: 'agent-runner', + event: 'task.retry', + message: 'Worker retry scheduled', + processId: 'worker-1', + runId: 'run-2', + context: { attempt: 2, reason: 'network jitter' }, + }, + ]; + } + + return [ + { + id: 'entry-1', + timestamp: '2026-04-07T11:00:00.000Z', + level: 'error', + source: 'dashboard', + event: 'logs.bootstrap', + message: 'Boot sequence failed for dashboard logging', + processId: 'api-1', + runId: 'run-1', + context: { component: 'dashboard', stage: 'bootstrap' }, + }, + { + id: 'entry-2', + timestamp: '2026-04-07T11:10:00.000Z', + level: 'warn', + source: 'agent-runner', + event: 'task.retry', + message: 'Worker retry scheduled', + processId: 'worker-1', + runId: 'run-2', + context: { attempt: 2, reason: 'network jitter' }, + }, + ]; +} + +function installFetchMock() { + fetchMock.mockImplementation((input) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + const parsed = new URL(url, 'http://localhost'); + + if (parsed.pathname === '/api/logs/config') { + return jsonResponse({ + logging: { + enabled: true, + level: 'info', + rotate_mb: 10, + retain_days: 7, + redact: true, + live_buffer_size: 250, + }, + }); + } + + if (parsed.pathname === '/api/logs/sources') { + return jsonResponse({ + sources: [ + { + source: 'dashboard', + label: 'Dashboard UI', + kind: 'native', + count: 18, + lastTimestamp: '2026-04-07T11:00:00.000Z', + }, + { + source: 'agent-runner', + label: 'Agent Runner', + kind: 'legacy', + count: 9, + lastTimestamp: '2026-04-07T11:10:00.000Z', + }, + ], + }); + } + + if (parsed.pathname === '/api/logs/entries') { + const source = parsed.searchParams.get('source') ?? 'all'; + const search = parsed.searchParams.get('search'); + const entries = buildEntries(source).filter((entry) => + search ? entry.message.toLowerCase().includes(search.toLowerCase()) : true + ); + return jsonResponse({ entries }); + } + + return Promise.reject(new Error(`Unhandled request: ${url}`)); + }); +} + +describe('LogsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + global.fetch = fetchMock; + }); + + it('shows the loading skeleton while the initial queries are pending', () => { + fetchMock.mockImplementation(() => new Promise(() => {})); + + render(); + + expect(screen.getByLabelText('Loading logs workspace')).toBeInTheDocument(); + }); + + it('filters by source and search query', async () => { + installFetchMock(); + + render(); + + expect( + (await screen.findAllByText('Boot sequence failed for dashboard logging')).length + ).toBeGreaterThan(0); + + await userEvent.click(screen.getByRole('combobox', { name: 'Source filter' })); + await userEvent.click(await screen.findByRole('option', { name: 'Agent Runner' })); + + expect((await screen.findAllByText('Worker retry scheduled')).length).toBeGreaterThan(0); + await waitFor(() => { + expect(screen.queryAllByText('Boot sequence failed for dashboard logging')).toHaveLength(0); + }); + + await userEvent.clear(screen.getByLabelText('Search')); + await userEvent.type(screen.getByLabelText('Search'), 'retry'); + + await waitFor(() => { + expect( + fetchMock.mock.calls.some((call) => + String(call[0]).includes('/api/logs/entries?source=agent-runner') + ) + ).toBe(true); + expect(fetchMock.mock.calls.some((call) => String(call[0]).includes('search=retry'))).toBe( + true + ); + }); + }); + + it('shows the selected entry detail and raw context', async () => { + installFetchMock(); + + render(); + + expect( + (await screen.findAllByText('Boot sequence failed for dashboard logging')).length + ).toBeGreaterThan(0); + + await userEvent.click(screen.getByRole('button', { name: /Worker retry scheduled/i })); + + expect((await screen.findAllByText('task.retry')).length).toBeGreaterThan(0); + expect(screen.getByText('worker-1')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('tab', { name: /Raw context/i })); + + expect(await screen.findByText(/"reason": "network jitter"/)).toBeInTheDocument(); + expect(screen.getByText(/"runId": "run-2"/)).toBeInTheDocument(); + }); +}); From 4c15a072dc07841b8b5544d5d7d24b916dea8cae Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 7 Apr 2026 20:21:04 -0400 Subject: [PATCH 3/8] fix(ci): track dashboard logs components - unignore the new ui logs component directory - add the missing logs viewer components required by ui build Refs #926 --- ui/.gitignore | 2 + ui/src/components/logs/log-level-badge.tsx | 24 +++ ui/src/components/logs/logs-config-card.tsx | 190 ++++++++++++++++++ ui/src/components/logs/logs-detail-panel.tsx | 101 ++++++++++ ui/src/components/logs/logs-entry-list.tsx | 102 ++++++++++ ui/src/components/logs/logs-filters.tsx | 127 ++++++++++++ .../components/logs/logs-overview-cards.tsx | 90 +++++++++ ui/src/components/logs/logs-page-skeleton.tsx | 56 ++++++ ui/src/components/logs/utils.ts | 77 +++++++ 9 files changed, 769 insertions(+) create mode 100644 ui/src/components/logs/log-level-badge.tsx create mode 100644 ui/src/components/logs/logs-config-card.tsx create mode 100644 ui/src/components/logs/logs-detail-panel.tsx create mode 100644 ui/src/components/logs/logs-entry-list.tsx create mode 100644 ui/src/components/logs/logs-filters.tsx create mode 100644 ui/src/components/logs/logs-overview-cards.tsx create mode 100644 ui/src/components/logs/logs-page-skeleton.tsx create mode 100644 ui/src/components/logs/utils.ts 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/components/logs/log-level-badge.tsx b/ui/src/components/logs/log-level-badge.tsx new file mode 100644 index 00000000..adf85604 --- /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..b9551b72 --- /dev/null +++ b/ui/src/components/logs/logs-config-card.tsx @@ -0,0 +1,190 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Save, Settings2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +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'; + +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 +
+ + Keep retention, file rotation, redaction, and live tail depth aligned with the host. + +
+ +
+
+
+

Current posture

+

+ {config.enabled ? 'Logging is enabled' : 'Logging is disabled'} at{' '} + {config.level.toUpperCase()} and above. +

+
+ + {config.redact ? 'Redacted' : 'Plain'} + +
+
+ +
+
+
+ +

+ Turn the unified logging pipeline on or off. +

+
+ + setDraft((current) => ({ ...current, enabled: checked })) + } + /> +
+ +
+
+ +

+ Mask sensitive content before it lands in the log archive. +

+
+ + setDraft((current) => ({ ...current, redact: checked })) + } + /> +
+
+ +
+ + +
+ +
+
+ + + setDraft((current) => ({ + ...current, + rotate_mb: parseInteger(event.target.value, current.rotate_mb), + })) + } + /> +
+
+ + + setDraft((current) => ({ + ...current, + retain_days: parseInteger(event.target.value, current.retain_days), + })) + } + /> +
+
+ +
+ + + setDraft((current) => ({ + ...current, + live_buffer_size: parseInteger(event.target.value, current.live_buffer_size), + })) + } + /> +
+ +
+ + + {isDirty ? Unsaved changes : null} +
+
+
+ ); +} 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..4945a388 --- /dev/null +++ b/ui/src/components/logs/logs-detail-panel.tsx @@ -0,0 +1,101 @@ +import { FileJson, Info } from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import type { LogsEntry } from '@/lib/api-client'; +import { LogLevelBadge } from './log-level-badge'; +import { formatJson, formatLogTimestamp } from './utils'; + +function MetaRow({ label, value }: { label: string; value: string | number }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +export function LogsDetailPanel({ + entry, + sourceLabel, +}: { + entry: LogsEntry | null; + sourceLabel?: string; +}) { + if (!entry) { + return ( + + + Entry details + Select a log entry to inspect metadata and raw context. + + + Nothing selected yet. + + + ); + } + + return ( + + +
+ + + {sourceLabel ?? entry.source} + + + {formatLogTimestamp(entry.timestamp)} + +
+
+ {entry.event} + + {entry.message} + +
+
+ + + + + + Details + + + + Raw context + + + + +
+ + + + +
+
+ + + +
+                {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 ?? {},
+                })}
+              
+
+
+
+
+
+ ); +} 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..e3cc5b70 --- /dev/null +++ b/ui/src/components/logs/logs-entry-list.tsx @@ -0,0 +1,102 @@ +import { AlertCircle, Inbox, Loader2 } from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +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'; +import { formatLogTimestamp, formatRelativeLogTime } from './utils'; + +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 ( + + +
+
+ Recent entries + Latest matches for the active filter set. +
+ {isFetching ? : null} +
+
+ + {isLoading ? ( +
+ {[1, 2, 3, 4, 5].map((item) => ( +
+
+
+
+
+ ))} +
+ ) : entries.length === 0 ? ( +
+ +
+

No entries matched these filters.

+

Try broadening the source, severity, or search terms.

+
+
+ ) : ( + +
+ {entries.map((entry) => { + const isSelected = entry.id === selectedEntryId; + + return ( + + ); + })} +
+
+ )} + + + ); +} diff --git a/ui/src/components/logs/logs-filters.tsx b/ui/src/components/logs/logs-filters.tsx new file mode 100644 index 00000000..f23cb01e --- /dev/null +++ b/ui/src/components/logs/logs-filters.tsx @@ -0,0 +1,127 @@ +import { Search, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import type { LogsSource } from '@/lib/api-client'; +import type { LogsLevelFilter, LogsSourceFilter } from '@/hooks/use-logs'; +import { getLogLevelOptions, getSelectedSourceLabel } 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 sourceLabel = getSelectedSourceLabel(selectedSource, sources); + + return ( + + +
+ Log explorer + + Slice the unified CCS log stream by source, severity, or message content. + +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + onSearchChange(event.target.value)} + placeholder="Search message, event, process, or run ID" + className="pl-9" + /> +
+
+
+
+ ); +} 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..527a2315 --- /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 ( +
+ + + + +
+ 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'; + } +} From 382aa04ca086938fac0baf66f1380f73c528df98 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 7 Apr 2026 20:29:59 -0400 Subject: [PATCH 4/8] feat(logs-ui): redesign the log viewer workspace - reshape the page into a management-center style control surface - add activity and CLIProxy error tabs with denser list/detail ergonomics - switch filters to practical chip groups and update the page tests Refs #926 --- ui/src/components/logs/logs-config-card.tsx | 23 ++- ui/src/components/logs/logs-detail-panel.tsx | 26 ++- ui/src/components/logs/logs-entry-list.tsx | 49 +++-- ui/src/components/logs/logs-filters.tsx | 173 +++++++++------- .../components/logs/logs-overview-cards.tsx | 4 +- ui/src/pages/logs.tsx | 190 +++++++++++++----- ui/tests/unit/ui/pages/logs-page.test.tsx | 11 +- 7 files changed, 315 insertions(+), 161 deletions(-) diff --git a/ui/src/components/logs/logs-config-card.tsx b/ui/src/components/logs/logs-config-card.tsx index b9551b72..7232fdff 100644 --- a/ui/src/components/logs/logs-config-card.tsx +++ b/ui/src/components/logs/logs-config-card.tsx @@ -41,7 +41,7 @@ export function LogsConfigCard({ const isDirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(config), [config, draft]); return ( - +
@@ -52,10 +52,12 @@ export function LogsConfigCard({ -
+
-

Current posture

+

+ Current posture +

{config.enabled ? 'Logging is enabled' : 'Logging is disabled'} at{' '} {config.level.toUpperCase()} and above. @@ -68,7 +70,7 @@ export function LogsConfigCard({

-
+

@@ -84,7 +86,7 @@ export function LogsConfigCard({ />

-
+

@@ -109,7 +111,11 @@ export function LogsConfigCard({ setDraft((current) => ({ ...current, level: value as LogsConfig['level'] })) } > - + @@ -128,6 +134,7 @@ export function LogsConfigCard({ id="logs-rotate-mb" type="number" min={1} + className="border-border/70 bg-background/70" value={draft.rotate_mb} onChange={(event) => setDraft((current) => ({ @@ -143,6 +150,7 @@ export function LogsConfigCard({ id="logs-retain-days" type="number" min={1} + className="border-border/70 bg-background/70" value={draft.retain_days} onChange={(event) => setDraft((current) => ({ @@ -160,6 +168,7 @@ export function LogsConfigCard({ id="logs-buffer-size" type="number" min={1} + className="border-border/70 bg-background/70" value={draft.live_buffer_size} onChange={(event) => setDraft((current) => ({ @@ -170,7 +179,7 @@ export function LogsConfigCard({ />

-
+
) : ( - -
+ +
{entries.map((entry) => { const isSelected = entry.id === selectedEntryId; @@ -63,33 +65,44 @@ export function LogsEntryList({ type="button" onClick={() => onSelect(entry.id)} className={cn( - 'w-full rounded-xl border px-4 py-3 text-left transition-colors', + 'w-full rounded-2xl border px-4 py-3 text-left transition-colors', isSelected - ? 'border-primary bg-primary/5 shadow-sm' - : 'border-border bg-background hover:bg-muted/40' + ? 'border-primary/40 bg-primary/5 shadow-sm' + : 'border-border/70 bg-background/70 hover:border-primary/20 hover:bg-muted/30' )} > -
+
+
+

+ {formatRelativeLogTime(entry.timestamp)} +

+

+ {formatLogTimestamp(entry.timestamp)} +

+
{sourceLabels[entry.source] ?? entry.source} + {entry.event}
-

+

{entry.message}

+
+ PID {entry.processId ?? 'n/a'} + Run {entry.runId ?? 'n/a'} +
- {entry.level === 'error' ? ( - - ) : null} -
-
- {entry.event} - {formatRelativeLogTime(entry.timestamp)} - {formatLogTimestamp(entry.timestamp)}
+ {entry.level === 'error' ? ( +
+ + Elevated severity +
+ ) : null} ); })} diff --git a/ui/src/components/logs/logs-filters.tsx b/ui/src/components/logs/logs-filters.tsx index f23cb01e..ad6ac500 100644 --- a/ui/src/components/logs/logs-filters.tsx +++ b/ui/src/components/logs/logs-filters.tsx @@ -1,16 +1,10 @@ -import { Search, RefreshCw } from 'lucide-react'; +import { Search, RefreshCw, SlidersHorizontal } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import type { LogsSource } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; import type { LogsLevelFilter, LogsSourceFilter } from '@/hooks/use-logs'; import { getLogLevelOptions, getSelectedSourceLabel } from '@/hooks/use-logs'; @@ -40,74 +34,24 @@ export function LogsFilters({ isRefreshing: boolean; }) { const sourceLabel = getSelectedSourceLabel(selectedSource, sources); + const levels = getLogLevelOptions(); + const limits = [50, 100, 150, 250]; return ( - - + + +
+ + Explorer filters +
- Log explorer - Slice the unified CCS log stream by source, severity, or message content. + Narrow the stream the same way you would in a management console: start with source, + then severity, then message text.
-
- -
- - -
- -
- - -
- -
- - -
- +
@@ -117,10 +61,99 @@ export function LogsFilters({ value={search} onChange={(event) => onSearchChange(event.target.value)} placeholder="Search message, event, process, or run ID" - className="pl-9" + className="h-11 border-border/70 bg-background/70 pl-9" />
+ +
+
+ + {sourceLabel} +
+
+ + {sources.map((source) => ( + + ))} +
+
+ +
+ +
+ {levels.map((option) => ( + + ))} +
+
+ +
+
+

+ View window +

+

+ Keep the rendered slice tight when you are chasing a live issue. +

+
+
+ {limits.map((option) => ( + + ))} +
+ +
); diff --git a/ui/src/components/logs/logs-overview-cards.tsx b/ui/src/components/logs/logs-overview-cards.tsx index 527a2315..678f8548 100644 --- a/ui/src/components/logs/logs-overview-cards.tsx +++ b/ui/src/components/logs/logs-overview-cards.tsx @@ -70,7 +70,7 @@ export function LogsOverviewCards({ 0 ? ` • ${legacySources} legacy` : ''}`} icon={Database} accent="bg-sky-500/10 text-sky-700 dark:text-sky-300" /> @@ -81,7 +81,7 @@ export function LogsOverviewCards({ icon={Activity} accent="bg-violet-500/10 text-violet-700 dark:text-violet-300" /> -
+
Last ingested event:{' '} {formatLogTimestamp(latestTimestamp)}
diff --git a/ui/src/pages/logs.tsx b/ui/src/pages/logs.tsx index 943a89f0..4bf09ef3 100644 --- a/ui/src/pages/logs.tsx +++ b/ui/src/pages/logs.tsx @@ -1,7 +1,17 @@ -import { AlertCircle, ArrowRight, RefreshCw } from 'lucide-react'; +import { + AlertCircle, + ArrowRight, + RefreshCw, + ScrollText, + ShieldAlert, + TimerReset, +} from 'lucide-react'; import { Link } from 'react-router-dom'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +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'; @@ -34,36 +44,82 @@ export function LogsPage() { } return ( -
-
-
+
+
+
+ LOGS +
+
-

System logs

-
-

Unified CCS logging

-

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

+ + System logs +
+
+

+ Operational log workspace +

+

+ Work through the CCS event stream like a control center: source-first filtering, + dense entry inspection, and retention controls beside the live activity feed.

-
- - + +
+ + +
+ + Redaction +
+

+ {config.redact ? 'Active' : 'Disabled'} +

+

+ Sensitive fields are masked before persistence. +

+
+
+ + +
+ + Retention +
+

{config.retain_days} days

+

+ Rotation threshold {config.rotate_mb} MB. +

+
+
+
+ + +
@@ -83,25 +139,42 @@ export function LogsPage() { latestTimestamp={workspace.latestTimestamp} /> -
-
- - void Promise.all([workspace.sourcesQuery.refetch(), workspace.entriesQuery.refetch()]) - } - isRefreshing={workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching} - /> + + + Activity stream + CLIProxy errors + + + +
+
+ + void Promise.all([ + workspace.sourcesQuery.refetch(), + workspace.entriesQuery.refetch(), + ]) + } + isRefreshing={ + workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching + } + /> + updateConfig.mutate(payload)} + isPending={updateConfig.isPending} + /> +
-
-
+
- updateConfig.mutate(payload)} - isPending={updateConfig.isPending} - /> -
+ + + +

+ Legacy diagnostics +

+

+ CLIProxy request-failure viewer +

+

+ Keep the existing request-failure drilldown nearby while the structured CCS stream + matures. This tab preserves the old operational workflow instead of burying it + behind Home-only UI. +

+
+
+ +
+
); } diff --git a/ui/tests/unit/ui/pages/logs-page.test.tsx b/ui/tests/unit/ui/pages/logs-page.test.tsx index d4227e8c..1e119ff8 100644 --- a/ui/tests/unit/ui/pages/logs-page.test.tsx +++ b/ui/tests/unit/ui/pages/logs-page.test.tsx @@ -41,7 +41,7 @@ function buildEntries(source: string) { source: 'agent-runner', event: 'task.retry', message: 'Worker retry scheduled', - processId: 'worker-1', + processId: 4121, runId: 'run-2', context: { attempt: 2, reason: 'network jitter' }, }, @@ -56,7 +56,7 @@ function buildEntries(source: string) { source: 'dashboard', event: 'logs.bootstrap', message: 'Boot sequence failed for dashboard logging', - processId: 'api-1', + processId: 25582, runId: 'run-1', context: { component: 'dashboard', stage: 'bootstrap' }, }, @@ -67,7 +67,7 @@ function buildEntries(source: string) { source: 'agent-runner', event: 'task.retry', message: 'Worker retry scheduled', - processId: 'worker-1', + processId: 4121, runId: 'run-2', context: { attempt: 2, reason: 'network jitter' }, }, @@ -150,8 +150,7 @@ describe('LogsPage', () => { (await screen.findAllByText('Boot sequence failed for dashboard logging')).length ).toBeGreaterThan(0); - await userEvent.click(screen.getByRole('combobox', { name: 'Source filter' })); - await userEvent.click(await screen.findByRole('option', { name: 'Agent Runner' })); + await userEvent.click(screen.getByRole('button', { name: 'Agent Runner' })); expect((await screen.findAllByText('Worker retry scheduled')).length).toBeGreaterThan(0); await waitFor(() => { @@ -185,7 +184,7 @@ describe('LogsPage', () => { await userEvent.click(screen.getByRole('button', { name: /Worker retry scheduled/i })); expect((await screen.findAllByText('task.retry')).length).toBeGreaterThan(0); - expect(screen.getByText('worker-1')).toBeInTheDocument(); + expect(screen.getByText('4121')).toBeInTheDocument(); await userEvent.click(screen.getByRole('tab', { name: /Raw context/i })); From 42481c0bb96d69f433a40003c425706e2697153b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 8 Apr 2026 15:55:19 -0400 Subject: [PATCH 5/8] fix(logging): address review feedback on cleanup and log reader --- src/commands/cleanup-command.ts | 15 +- src/commands/command-catalog.ts | 2 +- src/services/logging/log-reader.ts | 79 ++++++-- tests/unit/commands/cleanup-command.test.ts | 56 ++++++ tests/unit/commands/command-catalog.test.ts | 17 +- .../unit/services/logging/log-reader.test.ts | 172 ++++++++++++++++++ .../services/logging/log-redaction.test.ts | 103 +++++++++++ 7 files changed, 410 insertions(+), 34 deletions(-) create mode 100644 tests/unit/commands/cleanup-command.test.ts create mode 100644 tests/unit/services/logging/log-reader.test.ts create mode 100644 tests/unit/services/logging/log-redaction.test.ts diff --git a/src/commands/cleanup-command.ts b/src/commands/cleanup-command.ts index a695b1d3..286f787a 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. */ @@ -32,23 +32,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 } 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/services/logging/log-reader.ts b/src/services/logging/log-reader.ts index 8485e8e8..7d8ef1b9 100644 --- a/src/services/logging/log-reader.ts +++ b/src/services/logging/log-reader.ts @@ -8,6 +8,15 @@ import { 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; @@ -19,16 +28,62 @@ function parseLogLine(line: string): LogEntry | null { function readCurrentFileEntries(): LogEntry[] { const currentLogPath = getCurrentLogPath(); if (!fs.existsSync(currentLogPath)) { + currentLogCache = null; return []; } - return fs + 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[] { @@ -40,28 +95,12 @@ function dedupeEntries(entries: LogEntry[]): LogEntry[] { } export function readLogEntries(options: ReadLogEntriesOptions = {}): LogEntry[] { + const limit = options.limit ?? 200; const entries = dedupeEntries([...readCurrentFileEntries(), ...getRecentLogEntries()]) - .filter((entry) => (options.source ? entry.source === options.source : true)) - .filter((entry) => (options.level ? entry.level === options.level : true)) - .filter((entry) => { - if (!options.search) { - return true; - } - const search = options.search.toLowerCase(); - return ( - entry.message.toLowerCase().includes(search) || - entry.event.toLowerCase().includes(search) || - entry.source.toLowerCase().includes(search) || - String(entry.processId).toLowerCase().includes(search) || - entry.runId.toLowerCase().includes(search) || - JSON.stringify(entry.context || {}) - .toLowerCase() - .includes(search) - ); - }) + .filter((entry) => matchesLogQuery(entry, options)) .sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); - return entries.slice(0, options.limit ?? 200); + return entries.slice(0, limit); } export function readLogSourceSummaries(): LogSourceSummary[] { diff --git a/tests/unit/commands/cleanup-command.test.ts b/tests/unit/commands/cleanup-command.test.ts new file mode 100644 index 00000000..bed8f528 --- /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 { 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 only top-level log sizes in dry-run mode', async () => { + const ccsLogsDir = getNativeLogsDir(); + const archiveDir = path.join(ccsLogsDir, 'archive'); + 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('Would delete 1 files (100.00 B)'); + expect(output).not.toContain('1.95 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-reader.test.ts b/tests/unit/services/logging/log-reader.test.ts new file mode 100644 index 00000000..56db3010 --- /dev/null +++ b/tests/unit/services/logging/log-reader.test.ts @@ -0,0 +1,172 @@ +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` + ); + + 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]`, + }, + }); + }); +}); From 6e39f338ce3a71a06b6f1afedad5a943dde8f297 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 8 Apr 2026 15:56:02 -0400 Subject: [PATCH 6/8] fix(logs-ui): stabilize the dashboard log layout --- ui/src/components/logs/log-level-badge.tsx | 2 +- ui/src/components/logs/logs-config-card.tsx | 313 ++++++---- ui/src/components/logs/logs-detail-panel.tsx | 325 +++++++--- ui/src/components/logs/logs-entry-list.tsx | 214 +++++-- ui/src/components/logs/logs-filters.tsx | 216 ++++--- ui/src/index.css | 47 ++ ui/src/pages/logs.tsx | 598 ++++++++++++++----- ui/tests/unit/ui/pages/logs-page.test.tsx | 11 +- 8 files changed, 1209 insertions(+), 517 deletions(-) diff --git a/ui/src/components/logs/log-level-badge.tsx b/ui/src/components/logs/log-level-badge.tsx index adf85604..e4de4bee 100644 --- a/ui/src/components/logs/log-level-badge.tsx +++ b/ui/src/components/logs/log-level-badge.tsx @@ -13,7 +13,7 @@ export function LogLevelBadge({ level, className }: { level: LogsLevel; classNam return ( JSON.stringify(draft) !== JSON.stringify(config), [config, draft]); return ( - - -
- - Logging policy +
+
+
+
+ +
+
+

+ Logging Policy +

+

+ Retention and privacy +

+
- - Keep retention, file rotation, redaction, and live tail depth aligned with the host. - - - -
-
-
-

- Current posture -

-

- {config.enabled ? 'Logging is enabled' : 'Logging is disabled'} at{' '} - {config.level.toUpperCase()} and above. -

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

- Turn the unified logging pipeline on or off. +

+
+
+ +

+ Enable structured logging

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

- Mask sensitive content before it lands in the log archive. +

+
+ +

+ Sanitize payload data

setDraft((current) => ({ ...current, redact: checked })) } + className="data-[state=checked]:bg-primary" />
-
- - + setDraft((current) => ({ ...current, level: value as LogsConfig['level'] })) + } > - - - - Error only - Warn and above - Info and above - Debug and above - - -
- -
-
- - - setDraft((current) => ({ - ...current, - rotate_mb: parseInteger(event.target.value, current.rotate_mb), - })) - } - /> + + + + + + Error Only + + + Warn + Above + + + Info + Above + + + Full Debug + + +
-
- - - setDraft((current) => ({ - ...current, - retain_days: parseInteger(event.target.value, current.retain_days), - })) - } - /> + +
+
+ + + setDraft((current) => ({ + ...current, + rotate_mb: parseInteger(event.target.value, current.rotate_mb), + })) + } + /> +
+
+ + + setDraft((current) => ({ + ...current, + retain_days: parseInteger(event.target.value, current.retain_days), + })) + } + /> +
-
- - - setDraft((current) => ({ - ...current, - live_buffer_size: parseInteger(event.target.value, current.live_buffer_size), - })) - } - /> -
- -
- - {isDirty ? Unsaved changes : null}
- - +
+ +
+
+ + + 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 index df3fb459..f2b219c5 100644 --- a/ui/src/components/logs/logs-detail-panel.tsx +++ b/ui/src/components/logs/logs-detail-panel.tsx @@ -1,16 +1,46 @@ -import { FileJson, Info } from 'lucide-react'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +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, formatLogTimestamp } from './utils'; +import { formatJson } from './utils'; -function MetaRow({ label, value }: { label: string; value: string | number }) { +function MetaRow({ + label, + value, + icon: Icon, +}: { + label: string; + value: string | number; + icon?: LucideIcon; +}) { return ( -
-

{label}

-

{value}

+
+
+
+ {Icon && ( + + )} +

+ {label} +

+
+
+
+

+ {value} +

); } @@ -24,92 +54,213 @@ export function LogsDetailPanel({ }) { if (!entry) { return ( - - - Inspector - - Select an activity row to inspect its operational metadata. - - - - Nothing selected yet. - - +
+
+
+
+ +
+
+
+

+ Inspector Standby +

+

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

+
+
); } return ( - - -
- - - {sourceLabel ?? entry.source} - - - {formatLogTimestamp(entry.timestamp)} - -
-
- {entry.event} - - {entry.message} - -
-
- - - - - - Details - - - - Raw context - - +
+ {/* Tactical Inspector Header */} +
+ {/* Pattern Overlay */} +
- -
- - - - +
+
+
+ +
+
+ + + {sourceLabel ?? entry.source} + +
-
-

- Context summary -

-

- This entry belongs to{' '} - {sourceLabel ?? entry.source}{' '} - and was emitted at the{' '} - {entry.level} threshold. Use - the raw tab for the exact structured payload. -

-
- - - - -
-                {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 ?? {},
+            
+ + + {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 index 3998303f..dc205f6a 100644 --- a/ui/src/components/logs/logs-entry-list.tsx +++ b/ui/src/components/logs/logs-entry-list.tsx @@ -1,10 +1,8 @@ -import { AlertCircle, Inbox, Loader2 } from 'lucide-react'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +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'; -import { formatLogTimestamp, formatRelativeLogTime } from './utils'; export function LogsEntryList({ entries, @@ -22,40 +20,79 @@ export function LogsEntryList({ isFetching: boolean; }) { return ( - - -
-
- Activity queue - - Dense operational feed for the active source and severity mix. - +
+
+
+
+
+

+ Live Entry Stream +

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

No entries matched these filters.

-

Try broadening the source, severity, or search terms.

+
+
+
+
+ +
+
+
+

+ 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; @@ -65,51 +102,114 @@ export function LogsEntryList({ type="button" onClick={() => onSelect(entry.id)} className={cn( - 'w-full rounded-2xl border px-4 py-3 text-left transition-colors', + 'group relative flex w-full items-center border-b border-border/5 px-0 py-2.5 text-left transition-all duration-150', isSelected - ? 'border-primary/40 bg-primary/5 shadow-sm' - : 'border-border/70 bg-background/70 hover:border-primary/20 hover:bg-muted/30' + ? 'z-10 bg-primary/[0.08] shadow-[inset_4px_0_0_rgba(var(--primary),1)]' + : 'bg-transparent hover:bg-muted/30' )} > -
-
-

- {formatRelativeLogTime(entry.timestamp)} -

-

- {formatLogTimestamp(entry.timestamp)} +

+ +
+
+

+ {new Date(entry.timestamp).toLocaleTimeString(undefined, { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })}

-
-
- - + +
+
+ +
+ +
+ {sourceLabels[entry.source] ?? entry.source} - {entry.event} + + {entry.event} +
-

- {entry.message} -

-
- PID {entry.processId ?? 'n/a'} - Run {entry.runId ?? 'n/a'} + +
+

+ {entry.message} +

+
+ +
+ {entry.processId ?? '????'} +
+
+ {entry.runId?.slice(0, 4).toUpperCase() ?? 'NONE'} +
+
+
+ +
- {entry.level === 'error' ? ( -
- - Elevated severity -
- ) : null} ); })}
)} - - +
+ +
+
+ + 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 index ad6ac500..f212890d 100644 --- a/ui/src/components/logs/logs-filters.tsx +++ b/ui/src/components/logs/logs-filters.tsx @@ -1,12 +1,11 @@ -import { Search, RefreshCw, SlidersHorizontal } from 'lucide-react'; +import { Search, RefreshCw, Filter, Shield, Zap } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; 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, getSelectedSourceLabel } from '@/hooks/use-logs'; +import { getLogLevelOptions } from '@/hooks/use-logs'; export function LogsFilters({ sources, @@ -33,67 +32,76 @@ export function LogsFilters({ onRefresh: () => void; isRefreshing: boolean; }) { - const sourceLabel = getSelectedSourceLabel(selectedSource, sources); const levels = getLogLevelOptions(); const limits = [50, 100, 150, 250]; return ( - - -
- - Explorer filters -
-
- - Narrow the stream the same way you would in a management console: start with source, - then severity, then message text. - -
-
- -
- -
- - onSearchChange(event.target.value)} - placeholder="Search message, event, process, or run ID" - className="h-11 border-border/70 bg-background/70 pl-9" - /> -
-
- -
-
- - {sourceLabel} -
-
- + Payload Search + +
+ +
+
+
+ +
+ 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) => (
+
-
- -
- {levels.map((option) => ( - - ))} -
+ {/* Threshold Control */} +
+
+ +
+
+ {levels.map((option) => ( + + ))} +
+
-
-
-

- View window -

-

- Keep the rendered slice tight when you are chasing a live issue. -

+ {/* Operational Deck */} +
+
+
+
+

+ Operational Window +

+

+ Tail Capacity +

+
+
+ +
-
+ +
{limits.map((option) => ( ))}
-
- - +
+
); } 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/pages/logs.tsx b/ui/src/pages/logs.tsx index 4bf09ef3..e96d1ab9 100644 --- a/ui/src/pages/logs.tsx +++ b/ui/src/pages/logs.tsx @@ -1,41 +1,93 @@ +import { useEffect, useState } from 'react'; import { - AlertCircle, ArrowRight, + ChevronLeft, + ChevronRight, RefreshCw, ScrollText, - ShieldAlert, TimerReset, } from 'lucide-react'; import { Link } from 'react-router-dom'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; 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 { LogsOverviewCards } from '@/components/logs/logs-overview-cards'; 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 errors = [ - workspace.configQuery.error, - workspace.sourcesQuery.error, - workspace.entriesQuery.error, - ].filter(Boolean) as Error[]; + 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 ( -
- -
- ); + return ; } const config = workspace.configQuery.data; @@ -44,173 +96,389 @@ export function LogsPage() { } return ( -
-
-
- LOGS -
-
-
-
- - System logs +
+
+ +
+
+
+
+
-
-

- Operational log workspace -

-

- Work through the CCS event stream like a control center: source-first filtering, - dense entry inspection, and retention controls beside the live activity feed. +

+
+ + Operational + +

+ Log Operations Center +

+
+

+ CCS.TOC.LOGS.STREAM.v3

-
- - -
- +
+ +
+
+
+
+ Redaction -
-

- {config.redact ? 'Active' : 'Disabled'} -

-

- Sensitive fields are masked before persistence. -

- - - - -
- + + + {config.redact ? 'Enforced' : 'Standby'} + +
+
+
+
+ +
+
+ Retention -
-

{config.retain_days} days

-

- Rotation threshold {config.rotate_mb} MB. -

- - -
- - + + + {config.retain_days}D / {config.rotate_mb}MB + +
+ +
+ +
+ +
- {errors.length > 0 ? ( - - - Unable to fully load the logs workspace - {errors[0]?.message} - - ) : null} +
+ +
+ + + Telemetry Stream + + + Legacy Errors + + - - - - - Activity stream - CLIProxy errors - - - -
-
- - void Promise.all([ - workspace.sourcesQuery.refetch(), - workspace.entriesQuery.refetch(), - ]) - } - isRefreshing={ - workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching - } - /> - updateConfig.mutate(payload)} - isPending={updateConfig.isPending} - /> +
+
+ + + + + + Connected + +
+ + {workspace.entriesQuery.data?.length ?? 0} captured +
- - -
- - - - -

- Legacy diagnostics -

-

- CLIProxy request-failure viewer -

-

- Keep the existing request-failure drilldown nearby while the structured CCS stream - matures. This tab preserves the old operational workflow instead of burying it - behind Home-only UI. -

-
-
- -
- + + {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 index 1e119ff8..32521ffa 100644 --- a/ui/tests/unit/ui/pages/logs-page.test.tsx +++ b/ui/tests/unit/ui/pages/logs-page.test.tsx @@ -131,6 +131,11 @@ 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', () => { @@ -184,11 +189,11 @@ describe('LogsPage', () => { await userEvent.click(screen.getByRole('button', { name: /Worker retry scheduled/i })); expect((await screen.findAllByText('task.retry')).length).toBeGreaterThan(0); - expect(screen.getByText('4121')).toBeInTheDocument(); + expect(screen.getAllByText('4121').length).toBeGreaterThan(0); await userEvent.click(screen.getByRole('tab', { name: /Raw context/i })); - expect(await screen.findByText(/"reason": "network jitter"/)).toBeInTheDocument(); - expect(screen.getByText(/"runId": "run-2"/)).toBeInTheDocument(); + expect(await screen.findByText(/network jitter/)).toBeInTheDocument(); + expect(screen.getByText(/run-2/)).toBeInTheDocument(); }); }); From a47cf022c6a1afe4a87c712d98fa80f2cf76dd02 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 8 Apr 2026 16:06:45 -0400 Subject: [PATCH 7/8] test(logging): stabilize log reader cache refresh coverage --- tests/unit/services/logging/log-reader.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/services/logging/log-reader.test.ts b/tests/unit/services/logging/log-reader.test.ts index 56db3010..f7b1523d 100644 --- a/tests/unit/services/logging/log-reader.test.ts +++ b/tests/unit/services/logging/log-reader.test.ts @@ -113,6 +113,8 @@ describe('log reader', () => { }) )}\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); From 1923fce2ed103aa6345e2f9d62a647bd7df8f0c0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 8 Apr 2026 16:34:36 -0400 Subject: [PATCH 8/8] fix(logging): harden log storage and cleanup --- src/commands/cleanup-command.ts | 17 ++- src/services/logging/log-paths.ts | 4 +- tests/unit/commands/cleanup-command.test.ts | 10 +- tests/unit/services/logging/log-paths.test.ts | 11 ++ .../unit/services/logging/log-storage.test.ts | 118 ++++++++++++++++++ 5 files changed, 151 insertions(+), 9 deletions(-) create mode 100644 tests/unit/services/logging/log-storage.test.ts diff --git a/src/commands/cleanup-command.ts b/src/commands/cleanup-command.ts index 286f787a..223ae502 100644 --- a/src/commands/cleanup-command.ts +++ b/src/commands/cleanup-command.ts @@ -9,7 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCliproxyDir } from '../cliproxy/config-generator'; -import { getNativeLogsDir } from '../services/logging'; +import { getLogArchiveDir, getNativeLogsDir } from '../services/logging'; import { info, ok, warn } from '../utils/ui'; /** Default age in days for error log cleanup */ @@ -24,6 +24,10 @@ 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'; @@ -210,6 +214,7 @@ export async function handleCleanupCommand(args: string[]): Promise { 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; @@ -227,7 +232,13 @@ export async function handleCleanupCommand(args: string[]): Promise { if (cleanErrors) { await handleErrorLogCleanup(logsDir, maxAgeDays, dryRun, force); } else { - await handleMainLogCleanup({ cliproxyLogsDir: logsDir, ccsLogsDir, dryRun, force }); + await handleMainLogCleanup({ + cliproxyLogsDir: logsDir, + ccsLogsDir, + ccsArchiveDir, + dryRun, + force, + }); } } @@ -325,11 +336,13 @@ async function handleErrorLogCleanup( 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, diff --git a/src/services/logging/log-paths.ts b/src/services/logging/log-paths.ts index 3bc30c57..258e256d 100644 --- a/src/services/logging/log-paths.ts +++ b/src/services/logging/log-paths.ts @@ -23,8 +23,8 @@ export function getLegacyCliproxyLogsDir(): string { } export function ensureLoggingDirectories(): void { - fs.mkdirSync(getNativeLogsDir(), { recursive: true }); - fs.mkdirSync(getLogArchiveDir(), { recursive: true }); + fs.mkdirSync(getNativeLogsDir(), { recursive: true, mode: 0o700 }); + fs.mkdirSync(getLogArchiveDir(), { recursive: true, mode: 0o700 }); } export function isPathInsideDirectory(candidatePath: string, rootDir: string): boolean { diff --git a/tests/unit/commands/cleanup-command.test.ts b/tests/unit/commands/cleanup-command.test.ts index bed8f528..1c560762 100644 --- a/tests/unit/commands/cleanup-command.test.ts +++ b/tests/unit/commands/cleanup-command.test.ts @@ -4,7 +4,7 @@ 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 { getNativeLogsDir } from '../../../src/services/logging'; +import { getLogArchiveDir, getNativeLogsDir } from '../../../src/services/logging'; describe('cleanup command', () => { let tempHome = ''; @@ -27,9 +27,9 @@ describe('cleanup command', () => { tempHome = ''; }); - it('reports only top-level log sizes in dry-run mode', async () => { + it('reports CCS archives alongside current logs in dry-run mode', async () => { const ccsLogsDir = getNativeLogsDir(); - const archiveDir = path.join(ccsLogsDir, 'archive'); + const archiveDir = getLogArchiveDir(); const cliproxyLogsDir = path.join(getCliproxyDir(), 'logs'); fs.mkdirSync(archiveDir, { recursive: true }); @@ -47,8 +47,8 @@ describe('cleanup command', () => { .join('\n'); expect(output).toContain('CCS Logs: 1 files (100.00 B)'); - expect(output).toContain('Would delete 1 files (100.00 B)'); - expect(output).not.toContain('1.95 KB'); + 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/services/logging/log-paths.test.ts b/tests/unit/services/logging/log-paths.test.ts index 4e1ce391..41ef7a84 100644 --- a/tests/unit/services/logging/log-paths.test.ts +++ b/tests/unit/services/logging/log-paths.test.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { + ensureLoggingDirectories, getCurrentLogPath, getLogArchiveDir, getNativeLogsDir, @@ -43,4 +44,14 @@ describe('logging path helpers', () => { 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-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); + }); +});