Merge pull request #927 from kaitranntt/kai/feat/926-ccs-logging-unification

feat(logging): unify CCS runtime logs and dashboard viewer
This commit is contained in:
Kai (Tam Nhu) Tran
2026-04-08 16:51:53 -04:00
committed by GitHub
56 changed files with 3826 additions and 65 deletions
+9 -1
View File
@@ -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
+9 -1
View File
@@ -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.
+1
View File
@@ -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`.
+10 -1
View File
@@ -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
```
+11
View File
@@ -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<void> {
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<void> {
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<void> {
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);
}
+6 -2
View File
@@ -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<ProxyStatus> {
const log: LogFn = verbose ? (msg) => console.error(`[proxy-detector] ${msg}`) : noopLog;
const log: LogFn = verbose ? (msg) => logger.debug('detect.verbose', msg, { port }) : noopLog;
// Validate port - fallback to default if invalid
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}`);
+5 -1
View File
@@ -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<LockResult> {
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)`);
+7
View File
@@ -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<ToolSanitizationProxyConfig>;
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 {
+66 -38
View File
@@ -1,7 +1,7 @@
/**
* Cleanup Command Handler
*
* Removes old CLIProxy logs to free up disk space.
* Removes old CCS and CLIProxy logs to free up disk space.
* Supports both main logs and error request logs with age-based filtering.
* Logs can accumulate to several GB without user awareness.
*/
@@ -9,6 +9,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCliproxyDir } from '../cliproxy/config-generator';
import { getLogArchiveDir, getNativeLogsDir } from '../services/logging';
import { info, ok, warn } from '../utils/ui';
/** Default age in days for error log cleanup */
@@ -19,6 +20,14 @@ function getLogsDir(): string {
return path.join(getCliproxyDir(), 'logs');
}
function getCcsLogsDir(): string {
return getNativeLogsDir();
}
function getCcsLogArchiveDir(): string {
return getLogArchiveDir();
}
/** Format bytes to human-readable size */
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
@@ -27,23 +36,20 @@ function formatBytes(bytes: number): string {
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
}
/** Calculate total size of a directory */
/** Calculate total size of regular top-level files in a directory */
function getDirSize(dirPath: string): number {
if (!fs.existsSync(dirPath)) return 0;
let totalSize = 0;
const files = fs.readdirSync(dirPath);
const entries = fs.readdirSync(dirPath);
for (const file of files) {
const filePath = path.join(dirPath, file);
for (const entry of entries) {
const filePath = path.join(dirPath, entry);
try {
const stats = fs.lstatSync(filePath); // Use lstat to detect symlinks
const stats = fs.lstatSync(filePath);
if (stats.isFile() && !stats.isSymbolicLink()) {
totalSize += stats.size;
} else if (stats.isDirectory() && !stats.isSymbolicLink()) {
totalSize += getDirSize(filePath);
}
// Skip symlinks for safety
} catch {
// File may have been deleted between readdir and stat - skip
}
@@ -174,18 +180,18 @@ function printHelp(): void {
console.log('');
console.log('Usage: ccs cleanup [options]');
console.log('');
console.log('Remove old CLIProxy logs to free up disk space.');
console.log('Remove old CCS and CLIProxy logs to free up disk space.');
console.log('');
console.log('Options:');
console.log(' --errors Clean error request logs (error-*.log files)');
console.log(' --errors Clean legacy CLIProxy error request logs (error-*.log files)');
console.log(' --days=N Delete error logs older than N days (default: 7)');
console.log(' --dry-run Show what would be deleted without deleting');
console.log(' --force Skip confirmation prompt');
console.log(' --help, -h Show this help message');
console.log('');
console.log('Examples:');
console.log(' ccs cleanup Interactive main log cleanup');
console.log(' ccs cleanup --errors Clean error logs older than 7 days');
console.log(' ccs cleanup Interactive CCS + CLIProxy log cleanup');
console.log(' ccs cleanup --errors Clean legacy CLIProxy error logs older than 7 days');
console.log(' ccs cleanup --errors --days=3 Clean error logs older than 3 days');
console.log(' ccs cleanup --errors --dry-run Preview error log cleanup');
console.log(' ccs cleanup --dry-run Preview main log cleanup');
@@ -207,6 +213,8 @@ export async function handleCleanupCommand(args: string[]): Promise<void> {
const force = args.includes('--force');
const cleanErrors = args.includes('--errors');
const logsDir = getLogsDir();
const ccsLogsDir = getCcsLogsDir();
const ccsArchiveDir = getCcsLogArchiveDir();
// Parse --days=N option
let maxAgeDays = DEFAULT_ERROR_LOG_AGE_DAYS;
@@ -224,7 +232,13 @@ export async function handleCleanupCommand(args: string[]): Promise<void> {
if (cleanErrors) {
await handleErrorLogCleanup(logsDir, maxAgeDays, dryRun, force);
} else {
await handleMainLogCleanup(logsDir, dryRun, force);
await handleMainLogCleanup({
cliproxyLogsDir: logsDir,
ccsLogsDir,
ccsArchiveDir,
dryRun,
force,
});
}
}
@@ -319,40 +333,48 @@ async function handleErrorLogCleanup(
/**
* Handle main log cleanup (main.log and rotated files)
*/
async function handleMainLogCleanup(
logsDir: string,
dryRun: boolean,
force: boolean
): Promise<void> {
// Check if logs directory exists
if (!fs.existsSync(logsDir)) {
console.log(info('No CLIProxy logs found.'));
async function handleMainLogCleanup(options: {
cliproxyLogsDir: string;
ccsLogsDir: string;
ccsArchiveDir: string;
dryRun: boolean;
force: boolean;
}): Promise<void> {
const targets = [
{ label: 'CCS Logs', dir: options.ccsLogsDir },
{ label: 'CCS Log Archives', dir: options.ccsArchiveDir },
{ label: 'CLIProxy Logs', dir: options.cliproxyLogsDir },
].map((target) => ({
...target,
fileCount: countFiles(target.dir),
size: getDirSize(target.dir),
}));
const activeTargets = targets.filter((target) => target.fileCount > 0);
if (activeTargets.length === 0) {
console.log(info('No CCS or CLIProxy logs found.'));
return;
}
// Calculate current size
const currentSize = getDirSize(logsDir);
const fileCount = countFiles(logsDir);
const currentSize = activeTargets.reduce((sum, target) => sum + target.size, 0);
const fileCount = activeTargets.reduce((sum, target) => sum + target.fileCount, 0);
if (fileCount === 0) {
console.log(info('No log files to clean.'));
return;
console.log('');
console.log('Log Cleanup Targets:');
for (const target of activeTargets) {
console.log(` ${target.label}: ${target.fileCount} files (${formatBytes(target.size)})`);
console.log(` ${target.dir}`);
}
console.log('');
console.log(`CLIProxy Logs: ${logsDir}`);
console.log(` Files: ${fileCount}`);
console.log(` Size: ${formatBytes(currentSize)}`);
console.log('');
if (dryRun) {
if (options.dryRun) {
console.log(info('Dry run - no files deleted.'));
console.log(`Would delete ${fileCount} files (${formatBytes(currentSize)})`);
return;
}
// Confirm unless --force
if (!force) {
if (!options.force) {
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
@@ -371,13 +393,19 @@ async function handleMainLogCleanup(
}
// Perform cleanup
const { deleted, freedBytes } = cleanDirectory(logsDir);
let deleted = 0;
let freedBytes = 0;
for (const target of activeTargets) {
const result = cleanDirectory(target.dir);
deleted += result.deleted;
freedBytes += result.freedBytes;
}
console.log(ok(`Deleted ${deleted} files, freed ${formatBytes(freedBytes)}`));
// Suggest disabling logging if it was enabled
if (deleted > 0) {
console.log('');
console.log(warn('Tip: CLIProxy logging is now disabled by default.'));
console.log(' Run `ccs doctor --fix` to update your config.');
console.log(warn('Tip: CCS logging is bounded by retention, but you can lower it further.'));
console.log(' Open `ccs config` and review the Logs settings.');
}
}
+1 -1
View File
@@ -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',
+24
View File
@@ -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);
}
+37
View File
@@ -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>): 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.
+33
View File
@@ -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<string, ProfileConfig>;
/** 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,
+14
View File
@@ -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}`);
}
+9
View File
@@ -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}`);
}
}
+13
View File
@@ -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';
+18
View File
@@ -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 = [];
}
+47
View File
@@ -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;
}
}
+40
View File
@@ -0,0 +1,40 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
const LOGS_DIR = 'logs';
const ARCHIVE_DIR = 'archive';
const CURRENT_LOG_FILE = 'current.jsonl';
export function getNativeLogsDir(): string {
return path.join(getCcsDir(), LOGS_DIR);
}
export function getCurrentLogPath(): string {
return path.join(getNativeLogsDir(), CURRENT_LOG_FILE);
}
export function getLogArchiveDir(): string {
return path.join(getNativeLogsDir(), ARCHIVE_DIR);
}
export function getLegacyCliproxyLogsDir(): string {
return path.join(getCcsDir(), 'cliproxy', 'logs');
}
export function ensureLoggingDirectories(): void {
fs.mkdirSync(getNativeLogsDir(), { recursive: true, mode: 0o700 });
fs.mkdirSync(getLogArchiveDir(), { recursive: true, mode: 0o700 });
}
export function isPathInsideDirectory(candidatePath: string, rootDir: string): boolean {
const resolvedCandidate = path.resolve(candidatePath);
const resolvedRoot = path.resolve(rootDir);
const relative = path.relative(resolvedRoot, resolvedCandidate);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
export function buildArchiveLogPath(timestamp: Date = new Date()): string {
const compact = timestamp.toISOString().replace(/[:.]/g, '-');
return path.join(getLogArchiveDir(), `ccs-${compact}.jsonl.gz`);
}
+126
View File
@@ -0,0 +1,126 @@
import * as fs from 'fs';
import { getRecentLogEntries } from './log-buffer';
import { getCurrentLogPath } from './log-paths';
import {
isLoggingLevel,
type LogEntry,
type LogSourceSummary,
type ReadLogEntriesOptions,
} from './log-types';
type CurrentLogCache = {
entries: LogEntry[];
mtimeNs: bigint;
path: string;
size: bigint;
} | null;
let currentLogCache: CurrentLogCache = null;
function parseLogLine(line: string): LogEntry | null {
try {
return JSON.parse(line) as LogEntry;
} catch {
return null;
}
}
function readCurrentFileEntries(): LogEntry[] {
const currentLogPath = getCurrentLogPath();
if (!fs.existsSync(currentLogPath)) {
currentLogCache = null;
return [];
}
const stats = fs.statSync(currentLogPath, { bigint: true });
if (
currentLogCache &&
currentLogCache.path === currentLogPath &&
currentLogCache.mtimeNs === stats.mtimeNs &&
currentLogCache.size === stats.size
) {
return [...currentLogCache.entries];
}
const entries = fs
.readFileSync(currentLogPath, 'utf8')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map(parseLogLine)
.filter((entry): entry is LogEntry => entry !== null);
currentLogCache = {
entries,
mtimeNs: stats.mtimeNs,
path: currentLogPath,
size: stats.size,
};
return [...entries];
}
function matchesLogQuery(entry: LogEntry, options: ReadLogEntriesOptions): boolean {
if (options.source && entry.source !== options.source) {
return false;
}
if (options.level && entry.level !== options.level) {
return false;
}
if (!options.search) {
return true;
}
const search = options.search.toLowerCase();
return (
entry.message.toLowerCase().includes(search) ||
entry.event.toLowerCase().includes(search) ||
entry.source.toLowerCase().includes(search) ||
String(entry.processId).toLowerCase().includes(search) ||
entry.runId.toLowerCase().includes(search) ||
JSON.stringify(entry.context || {})
.toLowerCase()
.includes(search)
);
}
function dedupeEntries(entries: LogEntry[]): LogEntry[] {
const seen = new Map<string, LogEntry>();
for (const entry of entries) {
seen.set(entry.id, entry);
}
return [...seen.values()];
}
export function readLogEntries(options: ReadLogEntriesOptions = {}): LogEntry[] {
const limit = options.limit ?? 200;
const entries = dedupeEntries([...readCurrentFileEntries(), ...getRecentLogEntries()])
.filter((entry) => matchesLogQuery(entry, options))
.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
return entries.slice(0, limit);
}
export function readLogSourceSummaries(): LogSourceSummary[] {
const summaryMap = new Map<string, LogSourceSummary>();
for (const entry of readLogEntries({ limit: 500 })) {
const current = summaryMap.get(entry.source) ?? {
source: entry.source,
label: entry.source,
kind: 'native' as const,
count: 0,
lastTimestamp: null,
};
current.count += 1;
current.lastTimestamp = current.lastTimestamp ?? entry.timestamp;
summaryMap.set(entry.source, current);
}
return [...summaryMap.values()].sort((a, b) => a.label.localeCompare(b.label));
}
export function normalizeLogQueryLevel(level: string | undefined) {
return isLoggingLevel(level) ? level : undefined;
}
+62
View File
@@ -0,0 +1,62 @@
const SENSITIVE_KEY_PATTERN =
/^(authorization|cookie|set-cookie|password|password_hash|secret|token|api[_-]?key|management[_-]?key)$/i;
const MAX_STRING_LENGTH = 2000;
const MAX_DEPTH = 5;
function truncateString(value: string): string {
if (value.length <= MAX_STRING_LENGTH) {
return value;
}
return `${value.slice(0, MAX_STRING_LENGTH)}...[truncated]`;
}
function sanitizeValue(value: unknown, depth: number): unknown {
if (value === null || value === undefined) {
return value;
}
if (depth >= MAX_DEPTH) {
return '[max-depth]';
}
if (typeof value === 'string') {
return truncateString(value);
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value;
}
if (value instanceof Error) {
return {
name: value.name,
message: truncateString(value.message),
};
}
if (Array.isArray(value)) {
return value.map((item) => sanitizeValue(item, depth + 1));
}
if (typeof value === 'object') {
const sanitized: Record<string, unknown> = {};
for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
sanitized[key] = SENSITIVE_KEY_PATTERN.test(key)
? '[redacted]'
: sanitizeValue(nestedValue, depth + 1);
}
return sanitized;
}
return String(value);
}
export function redactContext(
context: Record<string, unknown> | undefined
): Record<string, unknown> {
if (!context) {
return {};
}
return sanitizeValue(context, 0) as Record<string, unknown>;
}
+94
View File
@@ -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.
}
}
+47
View File
@@ -0,0 +1,47 @@
import type { LoggingConfig, LoggingLevel } from '../../config/unified-config-types';
export type { LoggingConfig, LoggingLevel };
export interface LogEntry {
id: string;
timestamp: string;
level: LoggingLevel;
source: string;
event: string;
message: string;
processId: number;
runId: string;
context?: Record<string, unknown>;
}
export interface LogSourceSummary {
source: string;
label: string;
kind: 'native' | 'legacy';
count: number;
lastTimestamp: string | null;
}
export interface ReadLogEntriesOptions {
source?: string;
level?: LoggingLevel;
search?: string;
limit?: number;
}
export const LOG_LEVELS: readonly LoggingLevel[] = ['error', 'warn', 'info', 'debug'];
const LOG_LEVEL_PRIORITY: Record<LoggingLevel, number> = {
error: 0,
warn: 1,
info: 2,
debug: 3,
};
export function shouldWriteLogLevel(level: LoggingLevel, configuredLevel: LoggingLevel): boolean {
return LOG_LEVEL_PRIORITY[level] <= LOG_LEVEL_PRIORITY[configuredLevel];
}
export function isLoggingLevel(value: string | undefined): value is LoggingLevel {
return typeof value === 'string' && LOG_LEVELS.includes(value as LoggingLevel);
}
+67
View File
@@ -0,0 +1,67 @@
import { randomUUID } from 'crypto';
import { getResolvedLoggingConfig } from './log-config';
import { redactContext } from './log-redaction';
import { appendStructuredLogEntry } from './log-storage';
import type { LogEntry, LoggingLevel } from './log-types';
const processRunId = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
function createEntry(
source: string,
level: LoggingLevel,
event: string,
message: string,
context: Record<string, unknown>
): LogEntry {
const config = getResolvedLoggingConfig();
return {
id: randomUUID(),
timestamp: new Date().toISOString(),
level,
source,
event,
message,
processId: process.pid,
runId: processRunId,
context: config.redact ? redactContext(context) : context,
};
}
export interface Logger {
child(context: Record<string, unknown>): Logger;
debug(event: string, message: string, context?: Record<string, unknown>): void;
info(event: string, message: string, context?: Record<string, unknown>): void;
warn(event: string, message: string, context?: Record<string, unknown>): void;
error(event: string, message: string, context?: Record<string, unknown>): void;
}
export function createLogger(source: string, baseContext: Record<string, unknown> = {}): Logger {
const write = (
level: LoggingLevel,
event: string,
message: string,
context?: Record<string, unknown>
) => {
appendStructuredLogEntry(
createEntry(source, level, event, message, { ...baseContext, ...(context || {}) })
);
};
return {
child(context: Record<string, unknown>) {
return createLogger(source, { ...baseContext, ...context });
},
debug(event, message, context) {
write('debug', event, message, context);
},
info(event, message, context) {
write('info', event, message, context);
},
warn(event, message, context) {
write('warn', event, message, context);
},
error(event, message, context) {
write('error', event, message, context);
},
};
}
+7
View File
@@ -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(
+16
View File
@@ -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<ServerInstanc
next(err);
}
);
app.use(requestLoggingMiddleware);
// Session middleware (for dashboard auth)
app.use(createSessionMiddleware());
@@ -124,6 +129,12 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
// Start listening
return new Promise<ServerInstance>((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<ServerInstanc
const onListening = () => {
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 });
@@ -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();
}
+2
View File
@@ -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);
+99
View File
@@ -0,0 +1,99 @@
import { Router, type Request, type Response } from 'express';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
import { isLoggingLevel } from '../../services/logging/log-types';
import {
getDashboardLoggingConfig,
listDashboardLogEntries,
listDashboardLogSources,
updateDashboardLoggingConfig,
} from '../services/logs-dashboard-service';
const router = Router();
const LOGS_LOCAL_ACCESS_ERROR =
'Logs endpoints require localhost access when dashboard auth is disabled.';
router.use((req: Request, res: Response, next) => {
if (requireLocalAccessWhenAuthDisabled(req, res, LOGS_LOCAL_ACCESS_ERROR)) {
next();
}
});
router.get('/config', (_req: Request, res: Response) => {
res.json({ logging: getDashboardLoggingConfig() });
});
router.put('/config', (req: Request, res: Response) => {
const updates = req.body as Record<string, unknown>;
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
res.status(400).json({ error: 'Invalid request body. Must be an object.' });
return;
}
const {
enabled,
level,
rotate_mb: rotateMb,
retain_days: retainDays,
redact,
live_buffer_size: liveBufferSize,
} = updates;
if (enabled !== undefined && typeof enabled !== 'boolean') {
res.status(400).json({ error: 'enabled must be a boolean' });
return;
}
if (level !== undefined && !isLoggingLevel(String(level))) {
res.status(400).json({ error: 'level must be one of error, warn, info, debug' });
return;
}
const numericPairs = [
['rotate_mb', rotateMb],
['retain_days', retainDays],
['live_buffer_size', liveBufferSize],
] as const;
for (const [field, value] of numericPairs) {
if (value !== undefined && (!Number.isInteger(value) || Number(value) < 1)) {
res.status(400).json({ error: `${field} must be a positive integer` });
return;
}
}
if (redact !== undefined && typeof redact !== 'boolean') {
res.status(400).json({ error: 'redact must be a boolean' });
return;
}
res.json({
success: true,
logging: updateDashboardLoggingConfig({
enabled: enabled as boolean | undefined,
level: level as 'error' | 'warn' | 'info' | 'debug' | undefined,
rotate_mb: rotateMb as number | undefined,
retain_days: retainDays as number | undefined,
redact: redact as boolean | undefined,
live_buffer_size: liveBufferSize as number | undefined,
}),
});
});
router.get('/sources', (_req: Request, res: Response) => {
res.json({ sources: listDashboardLogSources() });
});
router.get('/entries', (req: Request, res: Response) => {
const { source, level, search, limit } = req.query;
const parsedLimit =
typeof limit === 'string' && Number.isInteger(Number(limit)) ? Number(limit) : undefined;
res.json({
entries: listDashboardLogEntries({
source: typeof source === 'string' ? source : undefined,
level: typeof level === 'string' && isLoggingLevel(level) ? level : undefined,
search: typeof search === 'string' ? search : undefined,
limit: parsedLimit,
}),
});
});
export default router;
@@ -0,0 +1,37 @@
import { mutateUnifiedConfig } from '../../config/unified-config-loader';
import {
getResolvedLoggingConfig,
invalidateLoggingConfigCache,
readLogEntries,
readLogSourceSummaries,
} from '../../services/logging';
import type { LoggingConfig } from '../../config/unified-config-types';
import type { ReadLogEntriesOptions } from '../../services/logging';
export function getDashboardLoggingConfig(): LoggingConfig {
return getResolvedLoggingConfig();
}
export function updateDashboardLoggingConfig(updates: Partial<LoggingConfig>): LoggingConfig {
const updated = mutateUnifiedConfig((config) => {
config.logging = {
...getResolvedLoggingConfig(),
...config.logging,
...updates,
};
});
invalidateLoggingConfigCache();
return {
...getResolvedLoggingConfig(),
...updated.logging,
};
}
export function listDashboardLogSources() {
return readLogSourceSummaries();
}
export function listDashboardLogEntries(options: ReadLogEntriesOptions = {}) {
return readLogEntries(options);
}
+24 -14
View File
@@ -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,
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { handleCleanupCommand } from '../../../src/commands/cleanup-command';
import { getCliproxyDir } from '../../../src/cliproxy/config-generator';
import { getLogArchiveDir, getNativeLogsDir } from '../../../src/services/logging';
describe('cleanup command', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cleanup-command-'));
process.env.CCS_HOME = tempHome;
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tempHome, { recursive: true, force: true });
tempHome = '';
});
it('reports CCS archives alongside current logs in dry-run mode', async () => {
const ccsLogsDir = getNativeLogsDir();
const archiveDir = getLogArchiveDir();
const cliproxyLogsDir = path.join(getCliproxyDir(), 'logs');
fs.mkdirSync(archiveDir, { recursive: true });
fs.mkdirSync(cliproxyLogsDir, { recursive: true });
fs.writeFileSync(path.join(ccsLogsDir, 'current.jsonl'), 'x'.repeat(100));
fs.writeFileSync(path.join(archiveDir, 'archived.jsonl.gz'), 'y'.repeat(2_000));
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
await handleCleanupCommand(['--dry-run']);
const output = logSpy.mock.calls
.flatMap((call) => call.map((value) => String(value)))
.join('\n');
expect(output).toContain('CCS Logs: 1 files (100.00 B)');
expect(output).toContain('CCS Log Archives: 1 files (1.95 KB)');
expect(output).toContain('Would delete 2 files (2.05 KB)');
} finally {
logSpy.mockRestore();
}
});
});
+13 -4
View File
@@ -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');
});
});
@@ -0,0 +1,57 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
ensureLoggingDirectories,
getCurrentLogPath,
getLogArchiveDir,
getNativeLogsDir,
isPathInsideDirectory,
} from '../../../../src/services/logging';
describe('logging path helpers', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-log-paths-'));
process.env.CCS_HOME = tempHome;
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tempHome, { recursive: true, force: true });
tempHome = '';
});
it('resolves native log paths inside the scoped CCS directory', () => {
expect(getNativeLogsDir()).toBe(path.join(tempHome, '.ccs', 'logs'));
expect(getCurrentLogPath()).toBe(path.join(tempHome, '.ccs', 'logs', 'current.jsonl'));
expect(getLogArchiveDir()).toBe(path.join(tempHome, '.ccs', 'logs', 'archive'));
});
it('rejects path escapes outside the CCS log root', () => {
const logsDir = getNativeLogsDir();
expect(isPathInsideDirectory(path.join(logsDir, 'archive', 'entry.gz'), logsDir)).toBe(true);
expect(isPathInsideDirectory(path.join(logsDir, '..', '..', 'etc', 'passwd'), logsDir)).toBe(
false
);
});
it('creates log directories with restrictive permissions', () => {
ensureLoggingDirectories();
const logsMode = fs.statSync(getNativeLogsDir()).mode & 0o777;
const archiveMode = fs.statSync(getLogArchiveDir()).mode & 0o777;
expect(logsMode).toBe(0o700);
expect(archiveMode).toBe(0o700);
});
});
@@ -0,0 +1,174 @@
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
clearRecentLogEntries,
pushRecentLogEntry,
} from '../../../../src/services/logging/log-buffer';
import { getCurrentLogPath } from '../../../../src/services/logging/log-paths';
import { readLogEntries } from '../../../../src/services/logging/log-reader';
import type { LogEntry } from '../../../../src/services/logging/log-types';
function createEntry(overrides: Partial<LogEntry>): LogEntry {
return {
id: overrides.id ?? `entry-${Math.random().toString(36).slice(2, 8)}`,
timestamp: overrides.timestamp ?? new Date().toISOString(),
level: overrides.level ?? 'info',
source: overrides.source ?? 'unit:test',
event: overrides.event ?? 'test.event',
message: overrides.message ?? 'message',
processId: overrides.processId ?? 1234,
runId: overrides.runId ?? 'run-1',
context: overrides.context ?? {},
};
}
describe('log reader', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-log-reader-'));
process.env.CCS_HOME = tempHome;
clearRecentLogEntries();
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
clearRecentLogEntries();
fs.rmSync(tempHome, { recursive: true, force: true });
tempHome = '';
});
it('caches unchanged current log file parses between reads', () => {
const currentLogPath = getCurrentLogPath();
fs.mkdirSync(path.dirname(currentLogPath), { recursive: true });
fs.writeFileSync(
currentLogPath,
`${JSON.stringify(
createEntry({
id: 'disk-entry',
message: 'Newest on-disk entry',
timestamp: '2026-04-08T11:00:00.000Z',
})
)}\n`
);
pushRecentLogEntry(
createEntry({
id: 'recent-1',
message: 'Buffered entry',
timestamp: '2026-04-08T10:00:00.000Z',
}),
250
);
const readSpy = spyOn(fs, 'readFileSync');
try {
const first = readLogEntries({ limit: 2 });
const second = readLogEntries({ limit: 2 });
expect(first.map((entry) => entry.id)).toEqual(['disk-entry', 'recent-1']);
expect(second.map((entry) => entry.id)).toEqual(['disk-entry', 'recent-1']);
expect(readSpy).toHaveBeenCalledTimes(1);
} finally {
readSpy.mockRestore();
}
});
it('refreshes the cached parse when the current log file changes', () => {
const currentLogPath = getCurrentLogPath();
fs.mkdirSync(path.dirname(currentLogPath), { recursive: true });
fs.writeFileSync(
currentLogPath,
`${JSON.stringify(
createEntry({
id: 'disk-old',
message: 'Older on-disk entry',
timestamp: '2026-04-08T11:00:00.000Z',
})
)}\n`
);
const readSpy = spyOn(fs, 'readFileSync');
try {
expect(readLogEntries({ limit: 1 }).map((entry) => entry.id)).toEqual(['disk-old']);
fs.writeFileSync(
currentLogPath,
`${JSON.stringify(
createEntry({
id: 'disk-new',
message: 'Newer on-disk entry',
timestamp: '2026-04-08T12:00:00.000Z',
})
)}\n`
);
const futureTimestamp = new Date(Date.now() + 10_000);
fs.utimesSync(currentLogPath, futureTimestamp, futureTimestamp);
expect(readLogEntries({ limit: 1 }).map((entry) => entry.id)).toEqual(['disk-new']);
expect(readSpy).toHaveBeenCalledTimes(2);
} finally {
readSpy.mockRestore();
}
});
it('keeps file-backed matches when buffered entries already satisfy the limit', () => {
const currentLogPath = getCurrentLogPath();
fs.mkdirSync(path.dirname(currentLogPath), { recursive: true });
fs.writeFileSync(
currentLogPath,
[
JSON.stringify(
createEntry({
id: 'disk-newest',
source: 'dashboard',
message: 'Newest dashboard entry on disk',
timestamp: '2026-04-08T12:30:00.000Z',
})
),
JSON.stringify(
createEntry({
id: 'disk-older',
source: 'dashboard',
message: 'Older dashboard entry on disk',
timestamp: '2026-04-08T10:30:00.000Z',
})
),
].join('\n') + '\n'
);
pushRecentLogEntry(
createEntry({
id: 'recent-middle',
source: 'dashboard',
message: 'Buffered dashboard entry',
timestamp: '2026-04-08T11:30:00.000Z',
}),
250
);
pushRecentLogEntry(
createEntry({
id: 'recent-oldest',
source: 'dashboard',
message: 'Oldest buffered dashboard entry',
timestamp: '2026-04-08T09:30:00.000Z',
}),
250
);
const entries = readLogEntries({ source: 'dashboard', limit: 2 });
expect(entries.map((entry) => entry.id)).toEqual(['disk-newest', 'recent-middle']);
});
});
@@ -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]`,
},
});
});
});
@@ -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>): 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);
});
});
+139
View File
@@ -0,0 +1,139 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import express from 'express';
import type { Server } from 'http';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import logsRoutes from '../../../src/web-server/routes/logs-routes';
import { createLogger } from '../../../src/services/logging';
import { clearRecentLogEntries } from '../../../src/services/logging/log-buffer';
describe('logs routes', () => {
let server: Server;
let baseUrl = '';
let forcedRemoteAddress = '127.0.0.1';
let tempHome = '';
let originalCcsHome: string | undefined;
let originalDashboardAuthEnabled: string | undefined;
beforeAll(async () => {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
Object.defineProperty(req.socket, 'remoteAddress', {
value: forcedRemoteAddress,
configurable: true,
});
next();
});
app.use('/api/logs', logsRoutes);
await new Promise<void>((resolve, reject) => {
server = app.listen(0, '127.0.0.1');
server.once('error', reject);
server.once('listening', () => resolve());
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-logs-routes-'));
process.env.CCS_HOME = tempHome;
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false';
forcedRemoteAddress = '127.0.0.1';
clearRecentLogEntries();
const logger = createLogger('unit:test');
logger.info('seed', 'Seed log entry', { feature: 'logs-routes' });
const legacyDir = path.join(tempHome, '.ccs', 'cliproxy', 'logs');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'error-legacy.log'), 'legacy error\n', 'utf8');
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (originalDashboardAuthEnabled !== undefined) {
process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled;
} else {
delete process.env.CCS_DASHBOARD_AUTH_ENABLED;
}
clearRecentLogEntries();
fs.rmSync(tempHome, { recursive: true, force: true });
tempHome = '';
});
it('returns logging config, sources, and entries', async () => {
const configResponse = await fetch(`${baseUrl}/api/logs/config`);
expect(configResponse.status).toBe(200);
const configPayload = (await configResponse.json()) as {
logging: { level: string; retain_days: number };
};
expect(configPayload.logging.level).toBe('info');
expect(configPayload.logging.retain_days).toBe(7);
const sourcesResponse = await fetch(`${baseUrl}/api/logs/sources`);
expect(sourcesResponse.status).toBe(200);
const sourcesPayload = (await sourcesResponse.json()) as {
sources: Array<{ source: string }>;
};
expect(sourcesPayload.sources.some((source) => source.source === 'unit:test')).toBe(true);
const entriesResponse = await fetch(`${baseUrl}/api/logs/entries?source=unit:test`);
expect(entriesResponse.status).toBe(200);
const entriesPayload = (await entriesResponse.json()) as {
entries: Array<{ source: string; message: string }>;
};
expect(entriesPayload.entries[0]?.source).toBe('unit:test');
expect(entriesPayload.entries[0]?.message).toBe('Seed log entry');
});
it('updates logging config through the route', async () => {
const response = await fetch(`${baseUrl}/api/logs/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
level: 'debug',
retain_days: 3,
live_buffer_size: 100,
}),
});
expect(response.status).toBe(200);
const payload = (await response.json()) as {
success: boolean;
logging: { level: string; retain_days: number; live_buffer_size: number };
};
expect(payload.success).toBe(true);
expect(payload.logging.level).toBe('debug');
expect(payload.logging.retain_days).toBe(3);
expect(payload.logging.live_buffer_size).toBe(100);
});
it('blocks remote access when dashboard auth is disabled', async () => {
forcedRemoteAddress = '10.10.0.42';
const response = await fetch(`${baseUrl}/api/logs/sources`);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({
error: 'Logs endpoints require localhost access when dashboard auth is disabled.',
});
});
});
+2
View File
@@ -1,5 +1,7 @@
# Logs
logs
!src/components/logs/
!src/components/logs/**
*.log
npm-debug.log*
yarn-debug.log*
+9
View File
@@ -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() {
</Suspense>
}
/>
<Route
path="/logs"
element={
<Suspense fallback={<PageLoader />}>
<LogsPage />
</Suspense>
}
/>
<Route
path="/shared"
element={
+2
View File
@@ -10,6 +10,7 @@ import {
ChevronRight,
BarChart3,
Gauge,
ScrollText,
Github,
Puzzle,
TerminalSquare,
@@ -127,6 +128,7 @@ function buildNavGroups(t: (key: string) => 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') },
],
},
@@ -0,0 +1,24 @@
import type { LogsLevel } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import { getLevelLabel } from './utils';
const LEVEL_STYLES: Record<LogsLevel, string> = {
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 (
<span
className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium uppercase tracking-[0.12em]',
LEVEL_STYLES[level],
className
)}
>
{getLevelLabel(level)}
</span>
);
}
+282
View File
@@ -0,0 +1,282 @@
import { useEffect, useMemo, useState } from 'react';
import { Save, Settings2, ShieldAlert, RotateCcw, Activity } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import type { LogsConfig, UpdateLogsConfigPayload } from '@/lib/api-client';
import { cn } from '@/lib/utils';
function parseInteger(value: string, fallback: number) {
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed)) {
return fallback;
}
return Math.max(0, parsed);
}
export function LogsConfigCard({
config,
onSave,
isPending,
}: {
config: LogsConfig;
onSave: (payload: UpdateLogsConfigPayload) => void;
isPending: boolean;
}) {
const [draft, setDraft] = useState(config);
useEffect(() => {
setDraft(config);
}, [config]);
const isDirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(config), [config, draft]);
return (
<div className="group relative overflow-hidden rounded-2xl border-2 border-border/60 bg-card/40 p-1 shadow-lg transition-all hover:border-border">
<div className="flex items-center justify-between border-b border-border bg-muted/30 px-5 py-3">
<div className="flex items-center gap-3">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary/5 border border-primary/20">
<Settings2 className="h-3.5 w-3.5 text-primary" />
</div>
<div className="space-y-0.5">
<h3 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground">
Logging Policy
</h3>
<p className="text-[10px] font-medium uppercase tracking-[0.1em] text-foreground/45">
Retention and privacy
</p>
</div>
</div>
<div
className={cn(
'h-1.5 w-1.5 rounded-full',
config.enabled ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]' : 'bg-zinc-500'
)}
/>
</div>
<div className="space-y-6 p-5">
<div className="grid grid-cols-2 gap-2">
<div className="rounded-xl border border-border/40 bg-background/50 p-3 flex flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">
Active Status
</span>
<div className="flex items-center gap-2">
<span
className={cn(
'text-[11px] font-semibold uppercase tracking-[0.1em]',
config.enabled ? 'text-emerald-500' : 'text-zinc-500'
)}
>
{config.enabled ? 'Live' : 'Off'}
</span>
</div>
</div>
<div className="rounded-xl border border-border/40 bg-background/50 p-3 flex flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">
Redaction
</span>
<span
className={cn(
'text-[11px] font-semibold uppercase tracking-[0.1em]',
config.redact ? 'text-primary' : 'text-muted-foreground/40'
)}
>
{config.redact ? 'Enforced' : 'Plain'}
</span>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-4 rounded-xl border border-border/40 bg-background/20 px-4 py-3 transition-colors hover:bg-background/40">
<div className="space-y-0.5">
<Label
htmlFor="logs-enabled"
className="text-[12px] font-semibold uppercase tracking-[0.12em]"
>
Pipeline
</Label>
<p className="text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground/55">
Enable structured logging
</p>
</div>
<Switch
id="logs-enabled"
checked={draft.enabled}
onCheckedChange={(checked) =>
setDraft((current) => ({ ...current, enabled: checked }))
}
className="data-[state=checked]:bg-primary"
/>
</div>
<div className="flex items-center justify-between gap-4 rounded-xl border border-border/40 bg-background/20 px-4 py-3 transition-colors hover:bg-background/40">
<div className="space-y-0.5">
<Label
htmlFor="logs-redact"
className="text-[12px] font-semibold uppercase tracking-[0.12em]"
>
Masking
</Label>
<p className="text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground/55">
Sanitize payload data
</p>
</div>
<Switch
id="logs-redact"
checked={draft.redact}
onCheckedChange={(checked) =>
setDraft((current) => ({ ...current, redact: checked }))
}
className="data-[state=checked]:bg-primary"
/>
</div>
</div>
<div className="space-y-4 pt-2">
<div className="space-y-2">
<div className="flex items-center gap-2 px-1">
<ShieldAlert className="h-3 w-3 text-primary/40" />
<Label
htmlFor="logs-config-level"
className="text-[10px] font-semibold uppercase tracking-[0.12em] text-foreground/70"
>
Minimum Operational Threshold
</Label>
</div>
<Select
value={draft.level}
onValueChange={(value) =>
setDraft((current) => ({ ...current, level: value as LogsConfig['level'] }))
}
>
<SelectTrigger
id="logs-config-level"
className="h-10 rounded-xl border-2 border-border/40 bg-background/50 text-[12px] font-semibold uppercase tracking-[0.1em] focus:ring-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent className="border-2 border-border bg-card">
<SelectItem
value="error"
className="text-[11px] font-semibold uppercase tracking-[0.1em]"
>
Error Only
</SelectItem>
<SelectItem
value="warn"
className="text-[11px] font-semibold uppercase tracking-[0.1em]"
>
Warn + Above
</SelectItem>
<SelectItem
value="info"
className="text-[11px] font-semibold uppercase tracking-[0.1em]"
>
Info + Above
</SelectItem>
<SelectItem
value="debug"
className="text-[11px] font-semibold uppercase tracking-[0.1em]"
>
Full Debug
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label
htmlFor="logs-rotate-mb"
className="px-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-foreground/50"
>
Rotation (MB)
</Label>
<Input
id="logs-rotate-mb"
type="number"
min={1}
className="h-10 rounded-xl border-2 border-border/40 bg-background/50 font-mono text-[12px] font-medium focus-visible:ring-0"
value={draft.rotate_mb}
onChange={(event) =>
setDraft((current) => ({
...current,
rotate_mb: parseInteger(event.target.value, current.rotate_mb),
}))
}
/>
</div>
<div className="space-y-2">
<Label
htmlFor="logs-retain-days"
className="px-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-foreground/50"
>
Retain (Days)
</Label>
<Input
id="logs-retain-days"
type="number"
min={1}
className="h-10 rounded-xl border-2 border-border/40 bg-background/50 font-mono text-[12px] font-medium focus-visible:ring-0"
value={draft.retain_days}
onChange={(event) =>
setDraft((current) => ({
...current,
retain_days: parseInteger(event.target.value, current.retain_days),
}))
}
/>
</div>
</div>
</div>
<div className="flex flex-col gap-2 pt-4 border-t border-border">
<Button
onClick={() => onSave(draft)}
disabled={!isDirty || isPending}
className="h-10 w-full gap-2 rounded-xl bg-primary text-[11px] font-semibold uppercase tracking-[0.14em] shadow-lg shadow-primary/20 transition-all hover:scale-[1.02] active:scale-[0.98]"
>
<Save className="h-3.5 w-3.5" />
Commit Changes
</Button>
<Button
variant="ghost"
onClick={() => setDraft(config)}
disabled={!isDirty || isPending}
className="h-9 gap-2 text-[10px] font-medium uppercase tracking-[0.12em] text-foreground/45 hover:text-foreground"
>
<RotateCcw className="h-3 w-3" />
Rollback Draft
</Button>
</div>
</div>
<div className="flex items-center justify-between bg-muted/20 px-5 py-2">
<div className="flex items-center gap-1.5">
<Activity className="h-2.5 w-2.5 text-primary/40" />
<span className="text-[9px] font-medium uppercase tracking-[0.12em] text-foreground/30">
Operational Logic v3.4
</span>
</div>
{isDirty && (
<div className="flex items-center gap-1">
<div className="h-1 w-1 rounded-full bg-amber-500 animate-pulse" />
<span className="text-[9px] font-medium uppercase tracking-[0.12em] text-amber-500/70">
Pending
</span>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,266 @@
import {
FileJson,
Info,
ShieldCheck,
Terminal,
Fingerprint,
Database,
Cpu,
Activity,
type LucideIcon,
} from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { LogsEntry } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import { LogLevelBadge } from './log-level-badge';
import { formatJson } from './utils';
function MetaRow({
label,
value,
icon: Icon,
}: {
label: string;
value: string | number;
icon?: LucideIcon;
}) {
return (
<div className="group relative flex flex-col gap-1.5 rounded-xl border border-border/40 bg-background/40 p-3 transition-all hover:bg-background/80 hover:shadow-lg hover:shadow-black/5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{Icon && (
<Icon className="h-3 w-3 text-primary/40 group-hover:text-primary transition-colors" />
)}
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70 transition-colors group-hover:text-primary/60">
{label}
</p>
</div>
<div className="h-1 w-1 rounded-full bg-border/40 group-hover:bg-primary/40 transition-colors" />
</div>
<p className="truncate font-mono text-[13px] font-medium tracking-tight text-foreground/85 transition-colors group-hover:text-foreground">
{value}
</p>
</div>
);
}
export function LogsDetailPanel({
entry,
sourceLabel,
}: {
entry: LogsEntry | null;
sourceLabel?: string;
}) {
if (!entry) {
return (
<div className="flex h-full flex-col items-center justify-center p-8 text-center animate-in fade-in duration-1000">
<div className="relative mb-8">
<div className="absolute inset-0 animate-ping rounded-full bg-primary/5 p-12" />
<div className="relative rounded-full border-2 border-dashed border-border/40 p-10 bg-muted/5">
<Terminal className="h-10 w-10 text-muted-foreground/20" />
</div>
</div>
<div className="max-w-xs space-y-3">
<h3 className="text-[15px] font-semibold uppercase tracking-[0.14em] text-foreground/65">
Inspector Standby
</h3>
<p className="text-[13px] leading-relaxed text-muted-foreground/55 font-medium">
Select a telemetry node from the active data queue to perform deep analysis of its
operational context.
</p>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col bg-card/30 backdrop-blur-sm animate-in fade-in slide-in-from-right-4 duration-500">
{/* Tactical Inspector Header */}
<div className="relative shrink-0 border-b border-border bg-card/60 p-6 shadow-sm overflow-hidden">
{/* Pattern Overlay */}
<div className="absolute inset-0 opacity-[0.03] pointer-events-none [background-image:radial-gradient(circle_at_center,var(--primary)_1px,transparent_0)] [background-size:16px_16px]" />
<div className="relative space-y-6">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<LogLevelBadge level={entry.level} className="h-5 px-3 shadow-lg shadow-black/5" />
<div className="h-4 w-px bg-border/60" />
<div className="flex items-center gap-2 rounded-full border border-border bg-background/50 px-3 py-1 shadow-inner">
<Database className="h-3 w-3 text-primary/60" />
<span className="text-[11px] font-semibold uppercase tracking-[0.12em] text-foreground/75">
{sourceLabel ?? entry.source}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Activity className="h-3 w-3 animate-pulse text-emerald-500" />
<span className="font-mono text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground/45">
{new Date(entry.timestamp).toLocaleTimeString(undefined, {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</span>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="h-1 w-4 rounded-full bg-primary/40" />
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-primary/65">
Event
</p>
</div>
<h2 className="text-[24px] font-semibold tracking-tight text-foreground leading-tight break-words">
{entry.event}
</h2>
<div className="rounded-xl border-l-4 border-primary/20 bg-muted/20 p-4 shadow-inner">
<p className="text-[14px] font-medium leading-relaxed text-foreground/85 selection:bg-primary/20">
{entry.message}
</p>
</div>
</div>
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-6">
<Tabs defaultValue="details" className="space-y-8">
<TabsList className="grid h-auto w-full grid-cols-2 gap-1 rounded-xl border border-border/60 bg-muted/40 p-1">
<TabsTrigger
value="details"
className="min-w-0 gap-2 rounded-lg px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.1em] transition-all data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-sm"
>
<Info className="h-3.5 w-3.5" />
Details
</TabsTrigger>
<TabsTrigger
value="raw"
className="min-w-0 gap-2 rounded-lg px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.1em] transition-all data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-sm"
>
<FileJson className="h-3.5 w-3.5" />
Raw Context
</TabsTrigger>
</TabsList>
<TabsContent
value="details"
className="mt-0 space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-500"
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<MetaRow
label="Entry Signature"
value={entry.id.slice(0, 16) + '...'}
icon={Fingerprint}
/>
<MetaRow label="Telemetry Origin" value={entry.source} icon={Database} />
<MetaRow label="Process ID" value={entry.processId ?? 'NA'} icon={Cpu} />
<MetaRow
label="Operational Run"
value={entry.runId?.slice(0, 8) ?? 'NA'}
icon={ShieldCheck}
/>
</div>
<div className="relative overflow-hidden rounded-[2rem] border border-border bg-muted/10 p-1 shadow-inner group">
{/* Background Scanline */}
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-primary/[0.03] to-transparent h-[200%] -top-full animate-[scan_8s_linear_infinite] pointer-events-none" />
<div className="rounded-[calc(2rem-4px)] border border-dashed border-border/40 bg-background/50 p-6 space-y-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/5 border border-primary/20 text-primary shadow-inner">
<Terminal className="h-4 w-4" />
</div>
<div className="space-y-0.5">
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-primary">
Automated Summary
</p>
<p className="text-[10px] font-medium uppercase tracking-[0.1em] text-muted-foreground/55">
Quick interpretation
</p>
</div>
</div>
<p className="text-[13px] leading-relaxed text-muted-foreground/80 font-medium">
This telemetry node was captured from{' '}
<span className="rounded bg-muted/40 px-1.5 py-0.5 font-semibold text-foreground">
{sourceLabel ?? entry.source}
</span>
operating at the{' '}
<span
className={cn(
'rounded px-1.5 py-0.5 text-[11px] font-semibold uppercase tracking-[0.08em]',
entry.level === 'error'
? 'bg-red-500/10 text-red-500'
: entry.level === 'warn'
? 'bg-amber-500/10 text-amber-500'
: entry.level === 'info'
? 'bg-sky-500/10 text-sky-500'
: 'bg-zinc-500/10 text-zinc-500'
)}
>
{entry.level}
</span>{' '}
threshold. The operational payload indicates an event state of{' '}
<span className="font-semibold text-foreground">{entry.event}</span>.
</p>
</div>
</div>
</TabsContent>
<TabsContent
value="raw"
className="mt-0 animate-in fade-in slide-in-from-bottom-2 duration-500"
>
<div className="group relative rounded-2xl border-2 border-border bg-zinc-950 p-1 shadow-2xl transition-all hover:border-primary/20">
{/* Copy HUD */}
<div className="absolute right-4 top-4 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-[9px] font-medium uppercase tracking-[0.12em] text-white/45 backdrop-blur-md">
JSON.RAW.MODE
</div>
</div>
<ScrollArea className="h-[30rem] w-full rounded-xl p-6">
<pre className="font-mono text-[12px] leading-relaxed tracking-tight text-zinc-400 selection:bg-primary/40 selection:text-primary-foreground">
{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 ?? {},
})}
</pre>
</ScrollArea>
</div>
</TabsContent>
</Tabs>
</div>
</ScrollArea>
<div className="flex shrink-0 items-center justify-between border-t border-border bg-muted/5 px-6 py-3">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" />
<span className="text-[10px] font-medium uppercase tracking-[0.12em] text-foreground/35">
Node Verified
</span>
</div>
<div className="h-3 w-px bg-border/40" />
<span className="text-[10px] font-medium tabular-nums uppercase tracking-[0.12em] text-foreground/35">
{entry.id.slice(0, 8)}
</span>
</div>
<div className="rounded-full bg-primary/5 px-2 py-0.5 border border-primary/10">
<span className="text-[9px] font-medium uppercase tracking-[0.12em] text-primary/65">
CCS-TEC-v3
</span>
</div>
</div>
</div>
);
}
+215
View File
@@ -0,0 +1,215 @@
import { Activity, ArrowRight, Inbox, Loader2 } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
import type { LogsEntry } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import { LogLevelBadge } from './log-level-badge';
export function LogsEntryList({
entries,
selectedEntryId,
onSelect,
sourceLabels,
isLoading,
isFetching,
}: {
entries: LogsEntry[];
selectedEntryId: string | null;
onSelect: (entryId: string) => void;
sourceLabels: Record<string, string>;
isLoading: boolean;
isFetching: boolean;
}) {
return (
<div className="flex min-h-0 flex-1 flex-col bg-background/50 backdrop-blur-sm">
<div className="flex shrink-0 items-center justify-between border-b border-border bg-card/40 px-6 py-3 shadow-sm">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="h-2 w-2 animate-pulse rounded-full bg-primary shadow-[0_0_12px_rgba(var(--primary),0.6)]" />
<h2 className="text-[12px] font-semibold uppercase tracking-[0.14em] text-foreground">
Live Entry Stream
</h2>
</div>
<div className="h-4 w-px bg-border/60" />
<div className="flex items-center gap-2 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5">
<Activity className="h-3 w-3 text-emerald-500" />
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-emerald-600">
Live telemetry
</span>
</div>
</div>
<div className="flex items-center gap-3">
{isFetching && (
<div className="flex items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-3 py-1">
<Loader2 className="h-3 w-3 animate-spin text-primary" />
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-primary">
Syncing
</span>
</div>
)}
<span className="font-mono text-[10px] font-medium uppercase tracking-[0.16em] text-foreground/35">
NODE.01
</span>
</div>
</div>
<div className="flex items-center gap-0 border-b border-border bg-muted/30 px-0 py-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-foreground/45">
<div className="w-[6.5rem] shrink-0 px-6">Time</div>
<div className="w-14 shrink-0 border-l border-border/10 px-2 text-center">Lvl</div>
<div className="w-[15rem] shrink-0 border-l border-border/10 px-4">Source</div>
<div className="flex-1 border-l border-border/10 px-4">Message</div>
<div className="w-[5.5rem] shrink-0 border-l border-border/10 px-2 text-center">Proc</div>
<div className="w-[5.5rem] shrink-0 border-l border-border/10 px-3 text-center">Run</div>
<div className="w-11 shrink-0 border-l border-border/10 px-2 text-center">Open</div>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{isLoading ? (
<div className="space-y-1 p-2">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].map((item) => (
<div
key={item}
className="h-10 w-full animate-pulse rounded-lg border border-border/5 bg-muted/20"
/>
))}
</div>
) : entries.length === 0 ? (
<div className="flex h-full animate-in fade-in duration-1000 flex-col items-center justify-center gap-6 px-8 text-center">
<div className="relative">
<div className="absolute inset-0 animate-ping rounded-full bg-muted/5 p-12" />
<div className="relative rounded-full border border-dashed border-border/40 bg-muted/5 p-10">
<Inbox className="h-10 w-10 text-muted-foreground/20" />
</div>
</div>
<div className="space-y-2">
<p className="text-[13px] font-semibold uppercase tracking-[0.14em] text-foreground/55">
No matching entries
</p>
<p className="max-w-[18rem] text-[12px] font-medium leading-relaxed text-muted-foreground/60">
Your current source, level, or search filters are hiding the stream. Adjust them to
bring entries back into view.
</p>
</div>
</div>
) : (
<ScrollArea className="h-full">
<div className="flex flex-col">
{entries.map((entry) => {
const isSelected = entry.id === selectedEntryId;
return (
<button
key={entry.id}
type="button"
onClick={() => onSelect(entry.id)}
className={cn(
'group relative flex w-full items-center border-b border-border/5 px-0 py-2.5 text-left transition-all duration-150',
isSelected
? 'z-10 bg-primary/[0.08] shadow-[inset_4px_0_0_rgba(var(--primary),1)]'
: 'bg-transparent hover:bg-muted/30'
)}
>
<div className="absolute inset-y-0 left-0 w-1 origin-center scale-y-0 bg-primary transition-transform duration-300 group-hover:scale-y-100" />
<div className="flex w-full items-center gap-0">
<div className="flex w-[6.5rem] shrink-0 items-center">
<p
className={cn(
'px-6 font-mono text-[11px] font-semibold tabular-nums transition-colors',
isSelected
? 'text-primary'
: 'text-foreground/60 group-hover:text-foreground'
)}
>
{new Date(entry.timestamp).toLocaleTimeString(undefined, {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</p>
</div>
<div className="flex min-w-0 flex-1 items-center gap-0">
<div className="flex w-14 shrink-0 items-center justify-center opacity-80 transition-opacity group-hover:opacity-100">
<LogLevelBadge
level={entry.level}
className="origin-center scale-[0.85]"
/>
</div>
<div className="flex w-[15rem] shrink-0 flex-col gap-0.5 overflow-hidden px-4">
<span
className={cn(
'truncate text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors',
isSelected
? 'text-foreground'
: 'text-foreground/50 group-hover:text-foreground/80'
)}
>
{sourceLabels[entry.source] ?? entry.source}
</span>
<span className="truncate text-[10px] font-medium uppercase tracking-[0.1em] text-muted-foreground/55">
{entry.event}
</span>
</div>
<div className="min-w-0 flex-1 px-4">
<p
className={cn(
'truncate text-[13px] font-medium leading-5 transition-colors',
isSelected
? 'text-foreground'
: 'text-foreground/70 group-hover:text-foreground'
)}
>
{entry.message}
</p>
</div>
<div className="flex w-[5.5rem] shrink-0 items-center justify-center px-2 font-mono text-[10px] font-semibold tabular-nums tracking-[0.12em] text-foreground/30 transition-colors group-hover:text-primary/45">
{entry.processId ?? '????'}
</div>
<div className="flex w-[5.5rem] shrink-0 items-center justify-center px-3 font-mono text-[10px] font-semibold tabular-nums tracking-[0.12em] text-foreground/30 transition-colors group-hover:text-primary/45">
{entry.runId?.slice(0, 4).toUpperCase() ?? 'NONE'}
</div>
<div className="flex w-11 shrink-0 items-center justify-center px-2">
<div
className={cn(
'flex h-7 w-7 items-center justify-center rounded-full border transition-all',
isSelected
? 'animate-in zoom-in duration-300 border-primary/20 bg-primary/10 text-primary'
: 'border-transparent text-foreground/20 group-hover:border-border/40 group-hover:bg-background/80 group-hover:text-foreground/55'
)}
>
<ArrowRight className="h-3.5 w-3.5" />
</div>
</div>
</div>
</div>
</button>
);
})}
</div>
</ScrollArea>
)}
</div>
<div className="flex shrink-0 items-center justify-between border-t border-border bg-muted/5 px-6 py-2">
<div className="flex items-center gap-3">
<span className="text-[10px] font-medium uppercase tracking-[0.12em] text-foreground/35">
Node: CCS-CORE
</span>
<div className="h-1 w-1 rounded-full bg-border/40" />
<span className="text-[10px] font-medium uppercase tracking-[0.12em] text-foreground/35">
Status: Operational
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-medium uppercase tracking-[0.12em] text-foreground/35">
Entries: {entries.length}
</span>
</div>
</div>
</div>
);
}
+198
View File
@@ -0,0 +1,198 @@
import { Search, RefreshCw, Filter, Shield, Zap } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import type { LogsSource } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import type { LogsLevelFilter, LogsSourceFilter } from '@/hooks/use-logs';
import { getLogLevelOptions } from '@/hooks/use-logs';
export function LogsFilters({
sources,
selectedSource,
onSourceChange,
selectedLevel,
onLevelChange,
search,
onSearchChange,
limit,
onLimitChange,
onRefresh,
isRefreshing,
}: {
sources: LogsSource[];
selectedSource: LogsSourceFilter;
onSourceChange: (value: LogsSourceFilter) => void;
selectedLevel: LogsLevelFilter;
onLevelChange: (value: LogsLevelFilter) => void;
search: string;
onSearchChange: (value: string) => void;
limit: number;
onLimitChange: (value: number) => void;
onRefresh: () => void;
isRefreshing: boolean;
}) {
const levels = getLogLevelOptions();
const limits = [50, 100, 150, 250];
return (
<div className="flex flex-col gap-6 animate-in fade-in slide-in-from-left-2 duration-700">
<div className="space-y-3">
<div className="flex items-center justify-between px-1">
<div className="flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-primary shadow-[0_0_8px_rgba(var(--primary),0.5)]" />
<Label
htmlFor="logs-search"
className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/80"
>
Payload Search
</Label>
</div>
<Zap className="h-3 w-3 text-primary/20" />
</div>
<div className="group relative">
<div className="pointer-events-none absolute inset-y-0 left-3.5 flex items-center transition-all group-focus-within:translate-x-1">
<Search className="h-3.5 w-3.5 text-foreground/20 group-focus-within:text-primary transition-colors" />
</div>
<Input
id="logs-search"
aria-label="Search"
value={search}
onChange={(event) => 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"
/>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2 px-1">
<Filter className="h-3 w-3 text-primary/40" />
<Label className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/80">
Source Matrix
</Label>
</div>
<div className="flex flex-col gap-1" aria-label="Source filter">
<button
type="button"
onClick={() => onSourceChange('all')}
className={cn(
'group relative flex items-center justify-between rounded-lg border px-3 py-2 transition-all active:scale-[0.98]',
selectedSource === 'all'
? 'border-primary/50 bg-primary/10 text-primary shadow-[0_0_15px_rgba(var(--primary),0.1)]'
: 'border-border/40 bg-muted/20 text-foreground/40 hover:border-border hover:bg-muted/40 hover:text-foreground/80'
)}
>
<span className="text-[11px] font-semibold uppercase tracking-[0.12em]">
Global Stream
</span>
{selectedSource === 'all' && (
<div className="h-1.5 w-1.5 rounded-full bg-primary shadow-[0_0_8px_rgba(var(--primary),0.8)]" />
)}
</button>
<div className="grid grid-cols-2 gap-1 mt-1">
{sources.map((source) => (
<button
key={source.source}
type="button"
onClick={() => onSourceChange(source.source)}
className={cn(
'rounded-lg border px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-[0.1em] transition-all active:scale-[0.97]',
selectedSource === source.source
? 'border-primary/50 bg-primary/10 text-primary shadow-sm'
: 'border-border/40 bg-muted/20 text-foreground/40 hover:border-border hover:bg-muted/40 hover:text-foreground/80'
)}
>
{source.label}
</button>
))}
</div>
</div>
</div>
{/* Threshold Control */}
<div className="space-y-4">
<div className="flex items-center gap-2 px-1">
<Shield className="h-3 w-3 text-primary/40" />
<Label className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/80">
Sensitivity
</Label>
</div>
<div className="grid grid-cols-2 gap-1.5" aria-label="Level filter">
{levels.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onLevelChange(option.value)}
className={cn(
'flex flex-col items-center gap-1 rounded-lg border px-2 py-2 text-[10px] font-semibold uppercase tracking-[0.1em] transition-all active:scale-[0.97]',
selectedLevel === option.value
? 'border-primary/50 bg-primary/10 text-primary shadow-[0_0_15px_rgba(var(--primary),0.1)]'
: 'border-border/40 bg-muted/20 text-foreground/40 hover:border-border hover:bg-muted/40 hover:text-foreground/80'
)}
>
<span>{option.label}</span>
<div
className={cn(
'h-0.5 w-4 rounded-full transition-colors',
selectedLevel === option.value ? 'bg-primary' : 'bg-foreground/10'
)}
/>
</button>
))}
</div>
</div>
{/* Operational Deck */}
<div className="mt-4 rounded-2xl border-2 border-border bg-card/40 p-1.5 shadow-xl shadow-black/5">
<div className="rounded-[calc(1rem-2px)] border border-dashed border-border bg-background/60 p-4 space-y-5">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-primary">
Operational Window
</p>
<p className="text-[11px] font-medium uppercase tracking-[0.1em] text-foreground/45">
Tail Capacity
</p>
</div>
<div className="h-8 w-8 rounded-lg bg-primary/5 border border-primary/10 flex items-center justify-center">
<Zap className="h-4 w-4 text-primary/40" />
</div>
</div>
<div className="grid grid-cols-4 gap-1.5" aria-label="Visible entries">
{limits.map((option) => (
<button
key={option}
type="button"
onClick={() => onLimitChange(option)}
className={cn(
'rounded-md border py-1.5 text-[11px] font-semibold tabular-nums transition-all active:scale-[0.95]',
limit === option
? 'border-foreground bg-foreground text-background'
: 'border-border/60 bg-muted/40 text-foreground/40 hover:bg-muted hover:text-foreground'
)}
>
{option}
</button>
))}
</div>
<Button
variant="ghost"
size="sm"
className="group relative h-10 w-full overflow-hidden rounded-xl border-none bg-primary text-[11px] font-semibold uppercase tracking-[0.14em] text-primary-foreground transition-all hover:scale-[1.02] hover:shadow-lg hover:shadow-primary/20 active:scale-[0.98]"
onClick={onRefresh}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-1000" />
<div className="flex items-center gap-2">
<RefreshCw className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')} />
<span>Refresh Entries</span>
</div>
</Button>
</div>
</div>
</div>
);
}
@@ -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 (
<div className="rounded-xl border bg-card/80 p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{label}</p>
<p className="text-2xl font-semibold tracking-tight">{value}</p>
<p className="text-sm text-muted-foreground">{detail}</p>
</div>
<div className={cn('rounded-xl p-2.5', accent)}>
<Icon className="h-5 w-5" />
</div>
</div>
</div>
);
}
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 (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard
label="Pipeline"
value={config.enabled ? 'Enabled' : 'Disabled'}
detail={`Threshold: ${config.level.toUpperCase()} • Redaction ${config.redact ? 'on' : 'off'}`}
icon={RadioTower}
accent={
config.enabled
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
: 'bg-zinc-500/10 text-zinc-700 dark:text-zinc-300'
}
/>
<MetricCard
label="Retention"
value={`${config.retain_days}d`}
detail={`Rotate at ${config.rotate_mb} MB per file`}
icon={Archive}
accent="bg-amber-500/10 text-amber-700 dark:text-amber-300"
/>
<MetricCard
label="Coverage"
value={formatCount(sources.length)}
detail={`${nativeSources} active sources${legacySources > 0 ? `${legacySources} legacy` : ''}`}
icon={Database}
accent="bg-sky-500/10 text-sky-700 dark:text-sky-300"
/>
<MetricCard
label="Visible Entries"
value={formatCount(entries.length)}
detail={`${formatCount(errorCount)} errors • ${formatRelativeLogTime(latestTimestamp)}`}
icon={Activity}
accent="bg-violet-500/10 text-violet-700 dark:text-violet-300"
/>
<div className="md:col-span-2 xl:col-span-4 rounded-2xl border border-border/70 bg-card/70 px-4 py-3 text-sm text-muted-foreground shadow-sm">
Last ingested event:{' '}
<span className="font-medium text-foreground">{formatLogTimestamp(latestTimestamp)}</span>
</div>
</div>
);
}
@@ -0,0 +1,56 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export function LogsPageSkeleton() {
return (
<div className="space-y-6" aria-label="Loading logs workspace">
<Card className="gap-4">
<CardHeader className="space-y-3">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-8 w-72" />
<Skeleton className="h-4 w-[30rem]" />
</CardHeader>
</Card>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{[1, 2, 3, 4].map((item) => (
<Card key={item} className="gap-3">
<CardHeader className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-7 w-24" />
</CardHeader>
</Card>
))}
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_22rem]">
<Card className="gap-4">
<CardHeader className="space-y-3">
<Skeleton className="h-4 w-52" />
<div className="grid gap-3 md:grid-cols-4">
{[1, 2, 3, 4].map((item) => (
<Skeleton key={item} className="h-10 w-full" />
))}
</div>
</CardHeader>
<CardContent className="grid gap-4 xl:grid-cols-[22rem_minmax(0,1fr)]">
<Skeleton className="h-[26rem] w-full" />
<Skeleton className="h-[26rem] w-full" />
</CardContent>
</Card>
<Card className="gap-4">
<CardHeader className="space-y-3">
<Skeleton className="h-5 w-44" />
<Skeleton className="h-4 w-full" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3, 4].map((item) => (
<Skeleton key={item} className="h-10 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
);
}
+77
View File
@@ -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';
}
}
+149
View File
@@ -0,0 +1,149 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useDeferredValue, useMemo, useState } from 'react';
import { toast } from 'sonner';
import {
api,
type LogsEntry,
type LogsLevel,
type UpdateLogsConfigPayload,
} from '@/lib/api-client';
export type LogsLevelFilter = 'all' | LogsLevel;
export type LogsSourceFilter = 'all' | string;
const CONFIG_QUERY_KEY = ['logs', 'config'] as const;
const SOURCES_QUERY_KEY = ['logs', 'sources'] as const;
const DEFAULT_LIMIT = 150;
export function useLogsWorkspace() {
const [selectedSource, setSelectedSource] = useState<LogsSourceFilter>('all');
const [selectedLevel, setSelectedLevel] = useState<LogsLevelFilter>('all');
const [search, setSearch] = useState('');
const [limit, setLimit] = useState(DEFAULT_LIMIT);
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
const deferredSearch = useDeferredValue(search.trim());
const configQuery = useQuery({
queryKey: CONFIG_QUERY_KEY,
queryFn: async () => (await api.logs.getConfig()).logging,
refetchInterval: 30_000,
});
const sourcesQuery = useQuery({
queryKey: SOURCES_QUERY_KEY,
queryFn: async () => (await api.logs.getSources()).sources,
refetchInterval: 15_000,
});
const entriesQuery = useQuery({
queryKey: ['logs', 'entries', selectedSource, selectedLevel, deferredSearch, limit],
queryFn: async () =>
(
await api.logs.getEntries({
source: selectedSource === 'all' ? undefined : selectedSource,
level: selectedLevel === 'all' ? undefined : selectedLevel,
search: deferredSearch || undefined,
limit,
})
).entries,
placeholderData: keepPreviousData,
refetchInterval: 10_000,
});
const activeSelectedEntryId = useMemo(() => {
const nextEntries = entriesQuery.data ?? [];
if (nextEntries.length === 0) {
return null;
}
if (selectedEntryId && nextEntries.some((entry) => entry.id === selectedEntryId)) {
return selectedEntryId;
}
return nextEntries[0]?.id ?? null;
}, [entriesQuery.data, selectedEntryId]);
const selectedEntry = useMemo(
() => (entriesQuery.data ?? []).find((entry) => entry.id === activeSelectedEntryId) ?? null,
[activeSelectedEntryId, entriesQuery.data]
);
const latestTimestamp = useMemo(() => {
const timestamps = (sourcesQuery.data ?? [])
.map((source) => source.lastTimestamp)
.filter((value): value is string => Boolean(value));
return timestamps.sort((left, right) => right.localeCompare(left))[0] ?? null;
}, [sourcesQuery.data]);
return {
configQuery,
sourcesQuery,
entriesQuery,
selectedSource,
setSelectedSource,
selectedLevel,
setSelectedLevel,
search,
setSearch,
limit,
setLimit,
selectedEntryId: activeSelectedEntryId,
setSelectedEntryId,
selectedEntry,
latestTimestamp,
isInitialLoading:
(!configQuery.data && configQuery.isLoading) ||
(!sourcesQuery.data && sourcesQuery.isLoading) ||
(!entriesQuery.data && entriesQuery.isLoading),
};
}
export function useUpdateLogsConfig() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: UpdateLogsConfigPayload) => api.logs.updateConfig(payload),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: CONFIG_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: SOURCES_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ['logs', 'entries'] }),
]);
toast.success('Logging configuration saved.');
},
onError: (error: Error) => {
toast.error(error.message || 'Failed to save logging configuration.');
},
});
}
export function getLogLevelOptions(): Array<{ value: LogsLevelFilter; label: string }> {
return [
{ value: 'all', label: 'All levels' },
{ value: 'error', label: 'Errors' },
{ value: 'warn', label: 'Warnings' },
{ value: 'info', label: 'Info' },
{ value: 'debug', label: 'Debug' },
];
}
export function getSelectedSourceLabel(
source: LogsSourceFilter,
sources: Array<{ source: string; label: string }>
) {
if (source === 'all') {
return 'All sources';
}
return sources.find((entry) => entry.source === source)?.label ?? source;
}
export function getSourceLabelMap(
sources: Array<{ source: string; label: string }>
): Record<string, string> {
return Object.fromEntries(sources.map((source) => [source.source, source.label]));
}
export function isLogsEntryListEmpty(entries: LogsEntry[] | undefined) {
return !entries || entries.length === 0;
}
+47
View File
@@ -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;
}
+71
View File
@@ -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<LogsConfig>;
// 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: () =>
+1
View File
@@ -33,6 +33,7 @@ const resources = {
factoryDroid: 'Factory Droid',
system: 'System',
health: 'Health',
logs: 'Logs',
settings: 'Settings',
openrouterTooltip: 'Featured: OpenRouter + Alibaba Coding Plan + Ollama',
},
+23 -2
View File
@@ -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 */}
<AuthMonitor />
{/* Error Logs Monitor - shows only when there are errors */}
<div className="rounded-xl border bg-card/70 p-5">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-start gap-3">
<div className="rounded-xl bg-muted p-2.5">
<ScrollText className="h-5 w-5 text-muted-foreground" />
</div>
<div className="space-y-1">
<h2 className="text-lg font-semibold">Logs moved to a dedicated workspace</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Use the unified logs page for source-level filtering, structured entry inspection,
and retention policy edits without crowding the home dashboard.
</p>
</div>
</div>
<Button variant="outline" className="gap-2" onClick={() => navigate('/logs')}>
Open logs
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
<ErrorLogsMonitor />
</div>
);
+484
View File
@@ -0,0 +1,484 @@
import { useEffect, useState } from 'react';
import {
ArrowRight,
ChevronLeft,
ChevronRight,
RefreshCw,
ScrollText,
TimerReset,
} from 'lucide-react';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import { ErrorLogsMonitor } from '@/components/error-logs-monitor';
import { LogsConfigCard } from '@/components/logs/logs-config-card';
import { LogsDetailPanel } from '@/components/logs/logs-detail-panel';
import { LogsEntryList } from '@/components/logs/logs-entry-list';
import { LogsFilters } from '@/components/logs/logs-filters';
import { LogsPageSkeleton } from '@/components/logs/logs-page-skeleton';
import { getSourceLabelMap, useLogsWorkspace, useUpdateLogsConfig } from '@/hooks/use-logs';
const DESKTOP_LOGS_BREAKPOINT = 1200;
const LEFT_PANEL_WIDTH = 336;
const RIGHT_PANEL_WIDTH = 368;
const COLLAPSED_PANEL_WIDTH = 52;
function CollapsedPaneToggle({
side,
label,
onExpand,
}: {
side: 'left' | 'right';
label: string;
onExpand: () => void;
}) {
return (
<div
className={cn(
'flex h-full w-full flex-col items-center justify-center gap-4 bg-muted/5',
side === 'left' ? 'border-r border-border' : 'border-l border-border'
)}
>
<Button
variant="ghost"
size="icon"
onClick={onExpand}
aria-label={`Show ${label.toLowerCase()}`}
className="h-9 w-9 rounded-xl border border-border/70 bg-background/85 shadow-sm"
>
{side === 'left' ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</Button>
<span
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-foreground/45"
style={{ writingMode: 'vertical-rl' }}
>
{label}
</span>
</div>
);
}
export function LogsPage() {
const workspace = useLogsWorkspace();
const updateConfig = useUpdateLogsConfig();
const sourceLabels = getSourceLabelMap(workspace.sourcesQuery.data ?? []);
const [isDesktopLayout, setIsDesktopLayout] = useState(() =>
typeof window !== 'undefined' ? window.innerWidth >= DESKTOP_LOGS_BREAKPOINT : false
);
const [isFiltersCollapsed, setIsFiltersCollapsed] = useState(false);
const [isDetailsCollapsed, setIsDetailsCollapsed] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia(`(min-width: ${DESKTOP_LOGS_BREAKPOINT}px)`);
const syncLayout = () => {
setIsDesktopLayout(window.innerWidth >= DESKTOP_LOGS_BREAKPOINT);
};
syncLayout();
mediaQuery.addEventListener('change', syncLayout);
return () => mediaQuery.removeEventListener('change', syncLayout);
}, []);
if (workspace.isInitialLoading) {
return <LogsPageSkeleton />;
}
const config = workspace.configQuery.data;
if (!config) {
return null;
}
return (
<div className="relative flex h-full min-h-full flex-col overflow-hidden border-t border-border/40 bg-background font-sans text-foreground antialiased selection:bg-primary/30 selection:text-primary">
<div className="pointer-events-none absolute inset-0 z-0 opacity-40 [background-image:radial-gradient(circle_at_1px_1px,rgba(38,38,36,0.08)_1px,transparent_0)] [background-size:14px_14px]" />
<div className="relative z-10 flex shrink-0 items-center justify-between border-b border-border/80 bg-card/95 px-6 py-2 backdrop-blur-xl shadow-md transition-all xl:px-8">
<div className="flex items-center gap-10">
<div className="flex items-center gap-4">
<div className="group flex h-9 w-9 items-center justify-center rounded-xl border-2 border-primary/20 bg-primary/5 text-primary shadow-inner transition-all hover:scale-110 active:scale-90">
<ScrollText className="h-4.5 w-4.5 transition-transform group-hover:rotate-6" />
</div>
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2.5">
<span className="rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-primary-foreground shadow-lg shadow-primary/20">
Operational
</span>
<h1 className="text-[17px] font-semibold tracking-tight text-foreground">
Log Operations Center
</h1>
</div>
<p className="font-mono text-[10px] font-medium uppercase tracking-[0.16em] text-foreground/45">
CCS.TOC.LOGS.STREAM.v3
</p>
</div>
</div>
<div className="hidden h-8 w-px bg-border/80 md:block" />
<div className="hidden items-center gap-10 md:flex">
<div className="flex items-center gap-3">
<div
className={cn(
'h-2 w-2 rounded-full ring-4 transition-all duration-700',
config.redact
? 'bg-emerald-500 shadow-[0_0_20px_rgba(16,185,129,0.6)] ring-emerald-500/30'
: 'bg-zinc-600 ring-transparent'
)}
/>
<div className="flex flex-col">
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/90">
Redaction
</span>
<span className="text-[11px] font-medium text-foreground/50">
{config.redact ? 'Enforced' : 'Standby'}
</span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex h-7 w-7 items-center justify-center rounded-lg border-2 border-border bg-muted shadow-inner">
<TimerReset className="h-3.5 w-3.5 text-foreground" />
</div>
<div className="flex flex-col">
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/90">
Retention
</span>
<span className="text-[11px] font-medium text-foreground/50">
{config.retain_days}D / {config.rotate_mb}MB
</span>
</div>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
className="group h-9 gap-3 rounded-xl border-2 border-border bg-muted px-5 text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground transition-all hover:bg-foreground hover:text-background active:scale-95 shadow-lg shadow-black/5"
onClick={() =>
void Promise.all([workspace.sourcesQuery.refetch(), workspace.entriesQuery.refetch()])
}
>
<div className="relative">
<RefreshCw
className={cn(
'h-3.5 w-3.5 transition-transform duration-500',
(workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching) &&
'animate-spin'
)}
/>
{!workspace.entriesQuery.isFetching && !workspace.sourcesQuery.isFetching && (
<div className="absolute -right-1 -top-1 h-1.5 w-1.5 rounded-full border border-muted bg-primary animate-pulse" />
)}
</div>
{workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching
? 'Syncing'
: 'Refresh'}
</Button>
<div className="h-7 w-px bg-border/80" />
<Button
asChild
variant="ghost"
size="sm"
className="h-9 w-9 rounded-xl border-2 border-border bg-muted p-0 text-foreground transition-all hover:bg-foreground hover:text-background active:scale-95 shadow-lg shadow-black/5"
>
<Link to="/health">
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</div>
</div>
<div className="relative z-10 flex min-h-0 flex-1 overflow-hidden">
<Tabs defaultValue="stream" className="flex flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between border-b border-border/80 bg-card/80 px-6 py-2 backdrop-blur-xl shadow-inner xl:px-8">
<TabsList className="h-10 w-auto gap-1.5 rounded-xl border border-border/60 bg-muted/40 p-1">
<TabsTrigger
value="stream"
className="rounded-lg px-5 text-[11px] font-semibold uppercase tracking-[0.12em] text-foreground/60 transition-all data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-md"
>
Telemetry Stream
</TabsTrigger>
<TabsTrigger
value="errors"
className="rounded-lg px-5 text-[11px] font-semibold uppercase tracking-[0.12em] text-foreground/60 transition-all data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-md"
>
Legacy Errors
</TabsTrigger>
</TabsList>
<div className="hidden items-center gap-3 lg:flex">
<div className="flex items-center gap-2 rounded-full border border-border bg-muted px-3 py-1 shadow-inner">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.6)]"></span>
</span>
<span className="text-[11px] font-semibold uppercase tracking-tight text-foreground/80">
Connected
</span>
</div>
<span className="pr-4 text-[11px] font-medium tabular-nums text-foreground/45">
{workspace.entriesQuery.data?.length ?? 0} captured
</span>
</div>
</div>
<TabsContent
value="stream"
className="m-0 flex min-h-0 flex-1 overflow-y-auto lg:overflow-hidden focus-visible:outline-none"
>
{isDesktopLayout ? (
<div className="flex min-h-0 flex-1 overflow-hidden">
<div
data-logs-pane="filters"
style={{ width: isFiltersCollapsed ? COLLAPSED_PANEL_WIDTH : LEFT_PANEL_WIDTH }}
className="flex min-h-0 shrink-0 bg-muted/5"
>
{isFiltersCollapsed ? (
<CollapsedPaneToggle
side="left"
label="Filters"
onExpand={() => setIsFiltersCollapsed(false)}
/>
) : (
<div className="flex h-full min-h-0 w-full flex-col border-r border-border p-5 2xl:p-6">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/80">
Filters
</p>
<p className="text-[11px] text-muted-foreground/70">
Search, source, and retention controls
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setIsFiltersCollapsed(true)}
aria-label="Hide filters"
className="h-9 w-9 rounded-xl border border-border/70 bg-background/85 shadow-sm"
>
<ChevronLeft className="h-4 w-4" />
</Button>
</div>
<ScrollArea className="min-h-0 flex-1" data-logs-scroll-region="filters">
<div className="space-y-5 pr-4">
<LogsFilters
sources={workspace.sourcesQuery.data ?? []}
selectedSource={workspace.selectedSource}
onSourceChange={workspace.setSelectedSource}
selectedLevel={workspace.selectedLevel}
onLevelChange={workspace.setSelectedLevel}
search={workspace.search}
onSearchChange={workspace.setSearch}
limit={workspace.limit}
onLimitChange={workspace.setLimit}
onRefresh={() =>
void Promise.all([
workspace.sourcesQuery.refetch(),
workspace.entriesQuery.refetch(),
])
}
isRefreshing={
workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching
}
/>
<div className="border-t border-border/20 pt-5">
<LogsConfigCard
config={config}
onSave={(payload) => updateConfig.mutate(payload)}
isPending={updateConfig.isPending}
/>
</div>
</div>
</ScrollArea>
</div>
)}
</div>
<div
data-logs-pane="entries"
className="flex min-h-0 min-w-0 flex-1 overflow-hidden border-l border-r border-border bg-background/95"
>
<LogsEntryList
entries={workspace.entriesQuery.data ?? []}
selectedEntryId={workspace.selectedEntryId}
onSelect={workspace.setSelectedEntryId}
sourceLabels={sourceLabels}
isLoading={workspace.entriesQuery.isLoading}
isFetching={workspace.entriesQuery.isFetching}
/>
</div>
<div
data-logs-pane="details"
style={{ width: isDetailsCollapsed ? COLLAPSED_PANEL_WIDTH : RIGHT_PANEL_WIDTH }}
className="flex min-h-0 shrink-0 bg-muted/5 shadow-inner"
>
{isDetailsCollapsed ? (
<CollapsedPaneToggle
side="right"
label="Details"
onExpand={() => setIsDetailsCollapsed(false)}
/>
) : (
<div className="flex h-full min-h-0 w-full flex-col">
<div className="flex items-center justify-between border-b border-border/50 bg-background/60 px-3 py-2">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/80">
Details
</p>
<p className="text-[11px] text-muted-foreground/70">
Selected entry context and raw payload
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setIsDetailsCollapsed(true)}
aria-label="Hide details"
className="h-9 w-9 rounded-xl border border-border/70 bg-background/85 shadow-sm"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<LogsDetailPanel
entry={workspace.selectedEntry}
sourceLabel={
workspace.selectedEntry
? sourceLabels[workspace.selectedEntry.source]
: undefined
}
/>
</div>
</div>
)}
</div>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col">
<div className="border-b border-border bg-muted/5 p-5">
<div className="flex flex-col gap-6">
<LogsFilters
sources={workspace.sourcesQuery.data ?? []}
selectedSource={workspace.selectedSource}
onSourceChange={workspace.setSelectedSource}
selectedLevel={workspace.selectedLevel}
onLevelChange={workspace.setSelectedLevel}
search={workspace.search}
onSearchChange={workspace.setSearch}
limit={workspace.limit}
onLimitChange={workspace.setLimit}
onRefresh={() =>
void Promise.all([
workspace.sourcesQuery.refetch(),
workspace.entriesQuery.refetch(),
])
}
isRefreshing={
workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching
}
/>
<LogsConfigCard
config={config}
onSave={(payload) => updateConfig.mutate(payload)}
isPending={updateConfig.isPending}
/>
</div>
</div>
<div className="flex min-h-[32rem] flex-col overflow-hidden border-b border-border bg-background/95">
<LogsEntryList
entries={workspace.entriesQuery.data ?? []}
selectedEntryId={workspace.selectedEntryId}
onSelect={workspace.setSelectedEntryId}
sourceLabels={sourceLabels}
isLoading={workspace.entriesQuery.isLoading}
isFetching={workspace.entriesQuery.isFetching}
/>
</div>
<div className="flex min-h-[30rem] flex-col overflow-hidden bg-muted/5 shadow-inner">
<LogsDetailPanel
entry={workspace.selectedEntry}
sourceLabel={
workspace.selectedEntry
? sourceLabels[workspace.selectedEntry.source]
: undefined
}
/>
</div>
</div>
)}
</TabsContent>
<TabsContent
value="errors"
className="m-0 flex-1 overflow-y-auto bg-background/20 p-6 focus-visible:outline-none xl:p-8"
>
<div className="mx-auto max-w-5xl space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
<div className="relative overflow-hidden rounded-[2.5rem] border-2 border-border bg-card/40 p-1.5 shadow-2xl shadow-black/10">
<div className="absolute inset-0 opacity-[0.02] pointer-events-none [background-image:radial-gradient(circle_at_center,var(--primary)_1px,transparent_0)] [background-size:24px_24px]" />
<Card className="rounded-[calc(2.5rem-0.375rem)] border-none bg-background/60 shadow-none overflow-hidden backdrop-blur-md">
<CardContent className="flex flex-col gap-6 p-10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl border-2 border-primary/20 bg-primary/10 text-primary shadow-inner">
<ScrollText className="h-5 w-5" />
</div>
<div className="space-y-0.5">
<p className="text-[10px] font-black uppercase tracking-[0.4em] text-primary">
Legacy Diagnostic Node
</p>
<p className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground/40">
CCS-MATRIX-FAILURE-MONITOR
</p>
</div>
</div>
<div className="rounded-full border border-border bg-background/50 px-3 py-1 text-[9px] font-black uppercase tracking-widest text-foreground/40 shadow-inner">
Mode: Historical
</div>
</div>
<div className="space-y-3">
<h2 className="text-3xl font-black uppercase tracking-tighter text-foreground">
CLIProxy Failure Analysis
</h2>
<p className="max-w-3xl text-[15px] font-medium leading-relaxed text-muted-foreground/60">
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.
</p>
</div>
<div className="h-px w-full bg-gradient-to-r from-transparent via-border to-transparent opacity-40" />
</CardContent>
</Card>
</div>
<div className="rounded-[2.5rem] border-2 border-border bg-muted/5 p-8 shadow-inner backdrop-blur-sm">
<div className="mb-6 flex items-center gap-3">
<div className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary shadow-[0_0_8px_rgba(var(--primary),0.5)]" />
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-foreground/40">
Realtime Monitoring Deck
</span>
</div>
<ErrorLogsMonitor />
</div>
</div>
</TabsContent>
</Tabs>
</div>
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { LogsPage } from '@/pages/logs';
const fetchMock = vi.fn<typeof fetch>();
beforeAll(() => {
if (!Element.prototype.hasPointerCapture) {
Element.prototype.hasPointerCapture = () => false;
}
if (!Element.prototype.setPointerCapture) {
Element.prototype.setPointerCapture = () => undefined;
}
if (!Element.prototype.releasePointerCapture) {
Element.prototype.releasePointerCapture = () => undefined;
}
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => undefined;
}
});
function jsonResponse(body: unknown) {
return Promise.resolve(
new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
}
function buildEntries(source: string) {
if (source === 'agent-runner') {
return [
{
id: 'entry-2',
timestamp: '2026-04-07T11:10:00.000Z',
level: 'warn',
source: 'agent-runner',
event: 'task.retry',
message: 'Worker retry scheduled',
processId: 4121,
runId: 'run-2',
context: { attempt: 2, reason: 'network jitter' },
},
];
}
return [
{
id: 'entry-1',
timestamp: '2026-04-07T11:00:00.000Z',
level: 'error',
source: 'dashboard',
event: 'logs.bootstrap',
message: 'Boot sequence failed for dashboard logging',
processId: 25582,
runId: 'run-1',
context: { component: 'dashboard', stage: 'bootstrap' },
},
{
id: 'entry-2',
timestamp: '2026-04-07T11:10:00.000Z',
level: 'warn',
source: 'agent-runner',
event: 'task.retry',
message: 'Worker retry scheduled',
processId: 4121,
runId: 'run-2',
context: { attempt: 2, reason: 'network jitter' },
},
];
}
function installFetchMock() {
fetchMock.mockImplementation((input) => {
const url =
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
const parsed = new URL(url, 'http://localhost');
if (parsed.pathname === '/api/logs/config') {
return jsonResponse({
logging: {
enabled: true,
level: 'info',
rotate_mb: 10,
retain_days: 7,
redact: true,
live_buffer_size: 250,
},
});
}
if (parsed.pathname === '/api/logs/sources') {
return jsonResponse({
sources: [
{
source: 'dashboard',
label: 'Dashboard UI',
kind: 'native',
count: 18,
lastTimestamp: '2026-04-07T11:00:00.000Z',
},
{
source: 'agent-runner',
label: 'Agent Runner',
kind: 'legacy',
count: 9,
lastTimestamp: '2026-04-07T11:10:00.000Z',
},
],
});
}
if (parsed.pathname === '/api/logs/entries') {
const source = parsed.searchParams.get('source') ?? 'all';
const search = parsed.searchParams.get('search');
const entries = buildEntries(source).filter((entry) =>
search ? entry.message.toLowerCase().includes(search.toLowerCase()) : true
);
return jsonResponse({ entries });
}
return Promise.reject(new Error(`Unhandled request: ${url}`));
});
}
describe('LogsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
global.fetch = fetchMock;
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: 900,
});
});
it('shows the loading skeleton while the initial queries are pending', () => {
fetchMock.mockImplementation(() => new Promise<Response>(() => {}));
render(<LogsPage />);
expect(screen.getByLabelText('Loading logs workspace')).toBeInTheDocument();
});
it('filters by source and search query', async () => {
installFetchMock();
render(<LogsPage />);
expect(
(await screen.findAllByText('Boot sequence failed for dashboard logging')).length
).toBeGreaterThan(0);
await userEvent.click(screen.getByRole('button', { name: 'Agent Runner' }));
expect((await screen.findAllByText('Worker retry scheduled')).length).toBeGreaterThan(0);
await waitFor(() => {
expect(screen.queryAllByText('Boot sequence failed for dashboard logging')).toHaveLength(0);
});
await userEvent.clear(screen.getByLabelText('Search'));
await userEvent.type(screen.getByLabelText('Search'), 'retry');
await waitFor(() => {
expect(
fetchMock.mock.calls.some((call) =>
String(call[0]).includes('/api/logs/entries?source=agent-runner')
)
).toBe(true);
expect(fetchMock.mock.calls.some((call) => String(call[0]).includes('search=retry'))).toBe(
true
);
});
});
it('shows the selected entry detail and raw context', async () => {
installFetchMock();
render(<LogsPage />);
expect(
(await screen.findAllByText('Boot sequence failed for dashboard logging')).length
).toBeGreaterThan(0);
await userEvent.click(screen.getByRole('button', { name: /Worker retry scheduled/i }));
expect((await screen.findAllByText('task.retry')).length).toBeGreaterThan(0);
expect(screen.getAllByText('4121').length).toBeGreaterThan(0);
await userEvent.click(screen.getByRole('tab', { name: /Raw context/i }));
expect(await screen.findByText(/network jitter/)).toBeInTheDocument();
expect(screen.getByText(/run-2/)).toBeInTheDocument();
});
});