mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-23 20:25:20 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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)`);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export { createLogger } from './logger';
|
||||
export { getResolvedLoggingConfig, invalidateLoggingConfigCache } from './log-config';
|
||||
export { readLogEntries, readLogSourceSummaries, normalizeLogQueryLevel } from './log-reader';
|
||||
export { pruneExpiredLogArchives } from './log-storage';
|
||||
export {
|
||||
ensureLoggingDirectories,
|
||||
getCurrentLogPath,
|
||||
getLegacyCliproxyLogsDir,
|
||||
getLogArchiveDir,
|
||||
getNativeLogsDir,
|
||||
isPathInsideDirectory,
|
||||
} from './log-paths';
|
||||
export type { LogEntry, LogSourceSummary, LoggingLevel, ReadLogEntriesOptions } from './log-types';
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { LogEntry } from './log-types';
|
||||
|
||||
let recentEntries: LogEntry[] = [];
|
||||
|
||||
export function pushRecentLogEntry(entry: LogEntry, maxEntries: number): void {
|
||||
recentEntries.push(entry);
|
||||
if (recentEntries.length > maxEntries) {
|
||||
recentEntries = recentEntries.slice(recentEntries.length - maxEntries);
|
||||
}
|
||||
}
|
||||
|
||||
export function getRecentLogEntries(): LogEntry[] {
|
||||
return [...recentEntries];
|
||||
}
|
||||
|
||||
export function clearRecentLogEntries(): void {
|
||||
recentEntries = [];
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as fs from 'fs';
|
||||
import { DEFAULT_LOGGING_CONFIG } from '../../config/unified-config-types';
|
||||
import {
|
||||
getConfigYamlPath,
|
||||
getLoggingConfig as getUnifiedLoggingConfig,
|
||||
} from '../../config/unified-config-loader';
|
||||
import type { LoggingConfig } from './log-types';
|
||||
|
||||
const CACHE_RECHECK_MS = 1000;
|
||||
let cachedConfig: LoggingConfig = { ...DEFAULT_LOGGING_CONFIG };
|
||||
let cachedMtimeMs: number | null = null;
|
||||
let lastCheckedAt = 0;
|
||||
|
||||
export function invalidateLoggingConfigCache(): void {
|
||||
cachedConfig = { ...DEFAULT_LOGGING_CONFIG };
|
||||
cachedMtimeMs = null;
|
||||
lastCheckedAt = 0;
|
||||
}
|
||||
|
||||
export function getResolvedLoggingConfig(): LoggingConfig {
|
||||
const now = Date.now();
|
||||
if (now - lastCheckedAt < CACHE_RECHECK_MS) {
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
try {
|
||||
const configPath = getConfigYamlPath();
|
||||
const nextMtimeMs = fs.existsSync(configPath) ? fs.statSync(configPath).mtimeMs : null;
|
||||
if (nextMtimeMs === cachedMtimeMs) {
|
||||
lastCheckedAt = now;
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
cachedConfig = {
|
||||
...DEFAULT_LOGGING_CONFIG,
|
||||
...getUnifiedLoggingConfig(),
|
||||
};
|
||||
cachedMtimeMs = nextMtimeMs;
|
||||
lastCheckedAt = now;
|
||||
return cachedConfig;
|
||||
} catch {
|
||||
cachedConfig = { ...DEFAULT_LOGGING_CONFIG };
|
||||
cachedMtimeMs = null;
|
||||
lastCheckedAt = now;
|
||||
return cachedConfig;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../../utils/config-manager';
|
||||
|
||||
const LOGS_DIR = 'logs';
|
||||
const ARCHIVE_DIR = 'archive';
|
||||
const CURRENT_LOG_FILE = 'current.jsonl';
|
||||
|
||||
export function getNativeLogsDir(): string {
|
||||
return path.join(getCcsDir(), LOGS_DIR);
|
||||
}
|
||||
|
||||
export function getCurrentLogPath(): string {
|
||||
return path.join(getNativeLogsDir(), CURRENT_LOG_FILE);
|
||||
}
|
||||
|
||||
export function getLogArchiveDir(): string {
|
||||
return path.join(getNativeLogsDir(), ARCHIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyCliproxyLogsDir(): string {
|
||||
return path.join(getCcsDir(), 'cliproxy', 'logs');
|
||||
}
|
||||
|
||||
export function ensureLoggingDirectories(): void {
|
||||
fs.mkdirSync(getNativeLogsDir(), { recursive: true, 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`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
const SENSITIVE_KEY_PATTERN =
|
||||
/^(authorization|cookie|set-cookie|password|password_hash|secret|token|api[_-]?key|management[_-]?key)$/i;
|
||||
const MAX_STRING_LENGTH = 2000;
|
||||
const MAX_DEPTH = 5;
|
||||
|
||||
function truncateString(value: string): string {
|
||||
if (value.length <= MAX_STRING_LENGTH) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, MAX_STRING_LENGTH)}...[truncated]`;
|
||||
}
|
||||
|
||||
function sanitizeValue(value: unknown, depth: number): unknown {
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (depth >= MAX_DEPTH) {
|
||||
return '[max-depth]';
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return truncateString(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value instanceof Error) {
|
||||
return {
|
||||
name: value.name,
|
||||
message: truncateString(value.message),
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => sanitizeValue(item, depth + 1));
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
sanitized[key] = SENSITIVE_KEY_PATTERN.test(key)
|
||||
? '[redacted]'
|
||||
: sanitizeValue(nestedValue, depth + 1);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function redactContext(
|
||||
context: Record<string, unknown> | undefined
|
||||
): Record<string, unknown> {
|
||||
if (!context) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return sanitizeValue(context, 0) as Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as zlib from 'zlib';
|
||||
import { getResolvedLoggingConfig } from './log-config';
|
||||
import {
|
||||
ensureLoggingDirectories,
|
||||
getCurrentLogPath,
|
||||
buildArchiveLogPath,
|
||||
getLogArchiveDir,
|
||||
} from './log-paths';
|
||||
import { pushRecentLogEntry } from './log-buffer';
|
||||
import { shouldWriteLogLevel, type LogEntry } from './log-types';
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const PRUNE_INTERVAL_MS = 60 * 1000;
|
||||
let lastPruneAt = 0;
|
||||
|
||||
function getRotateBytes(rotateMb: number): number {
|
||||
return Math.max(1, rotateMb) * 1024 * 1024;
|
||||
}
|
||||
|
||||
function rotateCurrentLogIfNeeded(): void {
|
||||
const config = getResolvedLoggingConfig();
|
||||
const currentLogPath = getCurrentLogPath();
|
||||
|
||||
if (!fs.existsSync(currentLogPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(currentLogPath);
|
||||
const ageMs = Date.now() - stats.mtimeMs;
|
||||
const exceedsSize = stats.size >= getRotateBytes(config.rotate_mb);
|
||||
const exceedsAge = ageMs >= ONE_DAY_MS;
|
||||
if (!exceedsSize && !exceedsAge) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentContent = fs.readFileSync(currentLogPath, 'utf8');
|
||||
if (!currentContent.trim()) {
|
||||
fs.truncateSync(currentLogPath, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const archivePath = buildArchiveLogPath(new Date(stats.mtimeMs || Date.now()));
|
||||
fs.writeFileSync(archivePath, zlib.gzipSync(currentContent), { mode: 0o600 });
|
||||
fs.truncateSync(currentLogPath, 0);
|
||||
}
|
||||
|
||||
export function pruneExpiredLogArchives(): void {
|
||||
const config = getResolvedLoggingConfig();
|
||||
const archiveDir = getLogArchiveDir();
|
||||
if (!fs.existsSync(archiveDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cutoffMs = Date.now() - config.retain_days * ONE_DAY_MS;
|
||||
for (const entry of fs.readdirSync(archiveDir)) {
|
||||
const archivePath = path.join(archiveDir, entry);
|
||||
try {
|
||||
const stats = fs.lstatSync(archivePath);
|
||||
if (!stats.isFile() || stats.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
if (stats.mtimeMs < cutoffMs) {
|
||||
fs.unlinkSync(archivePath);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function appendStructuredLogEntry(entry: LogEntry): void {
|
||||
const config = getResolvedLoggingConfig();
|
||||
if (!config.enabled || !shouldWriteLogLevel(entry.level, config.level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ensureLoggingDirectories();
|
||||
rotateCurrentLogIfNeeded();
|
||||
fs.appendFileSync(getCurrentLogPath(), `${JSON.stringify(entry)}\n`, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
pushRecentLogEntry(entry, config.live_buffer_size);
|
||||
if (Date.now() - lastPruneAt >= PRUNE_INTERVAL_MS) {
|
||||
pruneExpiredLogArchives();
|
||||
lastPruneAt = Date.now();
|
||||
}
|
||||
} catch {
|
||||
// Logging must never break runtime behavior.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { LoggingConfig, LoggingLevel } from '../../config/unified-config-types';
|
||||
|
||||
export type { LoggingConfig, LoggingLevel };
|
||||
|
||||
export interface LogEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
level: LoggingLevel;
|
||||
source: string;
|
||||
event: string;
|
||||
message: string;
|
||||
processId: number;
|
||||
runId: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface LogSourceSummary {
|
||||
source: string;
|
||||
label: string;
|
||||
kind: 'native' | 'legacy';
|
||||
count: number;
|
||||
lastTimestamp: string | null;
|
||||
}
|
||||
|
||||
export interface ReadLogEntriesOptions {
|
||||
source?: string;
|
||||
level?: LoggingLevel;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export const LOG_LEVELS: readonly LoggingLevel[] = ['error', 'warn', 'info', 'debug'];
|
||||
|
||||
const LOG_LEVEL_PRIORITY: Record<LoggingLevel, number> = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
debug: 3,
|
||||
};
|
||||
|
||||
export function shouldWriteLogLevel(level: LoggingLevel, configuredLevel: LoggingLevel): boolean {
|
||||
return LOG_LEVEL_PRIORITY[level] <= LOG_LEVEL_PRIORITY[configuredLevel];
|
||||
}
|
||||
|
||||
export function isLoggingLevel(value: string | undefined): value is LoggingLevel {
|
||||
return typeof value === 'string' && LOG_LEVELS.includes(value as LoggingLevel);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { getResolvedLoggingConfig } from './log-config';
|
||||
import { redactContext } from './log-redaction';
|
||||
import { appendStructuredLogEntry } from './log-storage';
|
||||
import type { LogEntry, LoggingLevel } from './log-types';
|
||||
|
||||
const processRunId = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
function createEntry(
|
||||
source: string,
|
||||
level: LoggingLevel,
|
||||
event: string,
|
||||
message: string,
|
||||
context: Record<string, unknown>
|
||||
): LogEntry {
|
||||
const config = getResolvedLoggingConfig();
|
||||
return {
|
||||
id: randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
source,
|
||||
event,
|
||||
message,
|
||||
processId: process.pid,
|
||||
runId: processRunId,
|
||||
context: config.redact ? redactContext(context) : context,
|
||||
};
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
child(context: Record<string, unknown>): Logger;
|
||||
debug(event: string, message: string, context?: Record<string, unknown>): void;
|
||||
info(event: string, message: string, context?: Record<string, unknown>): void;
|
||||
warn(event: string, message: string, context?: Record<string, unknown>): void;
|
||||
error(event: string, message: string, context?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
export function createLogger(source: string, baseContext: Record<string, unknown> = {}): Logger {
|
||||
const write = (
|
||||
level: LoggingLevel,
|
||||
event: string,
|
||||
message: string,
|
||||
context?: Record<string, unknown>
|
||||
) => {
|
||||
appendStructuredLogEntry(
|
||||
createEntry(source, level, event, message, { ...baseContext, ...(context || {}) })
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
child(context: Record<string, unknown>) {
|
||||
return createLogger(source, { ...baseContext, ...context });
|
||||
},
|
||||
debug(event, message, context) {
|
||||
write('debug', event, message, context);
|
||||
},
|
||||
info(event, message, context) {
|
||||
write('info', event, message, context);
|
||||
},
|
||||
warn(event, message, context) {
|
||||
write('warn', event, message, context);
|
||||
},
|
||||
error(event, message, context) {
|
||||
write('error', event, message, context);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as 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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||
import { isLoggingLevel } from '../../services/logging/log-types';
|
||||
import {
|
||||
getDashboardLoggingConfig,
|
||||
listDashboardLogEntries,
|
||||
listDashboardLogSources,
|
||||
updateDashboardLoggingConfig,
|
||||
} from '../services/logs-dashboard-service';
|
||||
|
||||
const router = Router();
|
||||
const LOGS_LOCAL_ACCESS_ERROR =
|
||||
'Logs endpoints require localhost access when dashboard auth is disabled.';
|
||||
|
||||
router.use((req: Request, res: Response, next) => {
|
||||
if (requireLocalAccessWhenAuthDisabled(req, res, LOGS_LOCAL_ACCESS_ERROR)) {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/config', (_req: Request, res: Response) => {
|
||||
res.json({ logging: getDashboardLoggingConfig() });
|
||||
});
|
||||
|
||||
router.put('/config', (req: Request, res: Response) => {
|
||||
const updates = req.body as Record<string, unknown>;
|
||||
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
||||
res.status(400).json({ error: 'Invalid request body. Must be an object.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
enabled,
|
||||
level,
|
||||
rotate_mb: rotateMb,
|
||||
retain_days: retainDays,
|
||||
redact,
|
||||
live_buffer_size: liveBufferSize,
|
||||
} = updates;
|
||||
|
||||
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
||||
res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
return;
|
||||
}
|
||||
if (level !== undefined && !isLoggingLevel(String(level))) {
|
||||
res.status(400).json({ error: 'level must be one of error, warn, info, debug' });
|
||||
return;
|
||||
}
|
||||
|
||||
const numericPairs = [
|
||||
['rotate_mb', rotateMb],
|
||||
['retain_days', retainDays],
|
||||
['live_buffer_size', liveBufferSize],
|
||||
] as const;
|
||||
for (const [field, value] of numericPairs) {
|
||||
if (value !== undefined && (!Number.isInteger(value) || Number(value) < 1)) {
|
||||
res.status(400).json({ error: `${field} must be a positive integer` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (redact !== undefined && typeof redact !== 'boolean') {
|
||||
res.status(400).json({ error: 'redact must be a boolean' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
logging: updateDashboardLoggingConfig({
|
||||
enabled: enabled as boolean | undefined,
|
||||
level: level as 'error' | 'warn' | 'info' | 'debug' | undefined,
|
||||
rotate_mb: rotateMb as number | undefined,
|
||||
retain_days: retainDays as number | undefined,
|
||||
redact: redact as boolean | undefined,
|
||||
live_buffer_size: liveBufferSize as number | undefined,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/sources', (_req: Request, res: Response) => {
|
||||
res.json({ sources: listDashboardLogSources() });
|
||||
});
|
||||
|
||||
router.get('/entries', (req: Request, res: Response) => {
|
||||
const { source, level, search, limit } = req.query;
|
||||
const parsedLimit =
|
||||
typeof limit === 'string' && Number.isInteger(Number(limit)) ? Number(limit) : undefined;
|
||||
|
||||
res.json({
|
||||
entries: listDashboardLogEntries({
|
||||
source: typeof source === 'string' ? source : undefined,
|
||||
level: typeof level === 'string' && isLoggingLevel(level) ? level : undefined,
|
||||
search: typeof search === 'string' ? search : undefined,
|
||||
limit: parsedLimit,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { mutateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import {
|
||||
getResolvedLoggingConfig,
|
||||
invalidateLoggingConfigCache,
|
||||
readLogEntries,
|
||||
readLogSourceSummaries,
|
||||
} from '../../services/logging';
|
||||
import type { LoggingConfig } from '../../config/unified-config-types';
|
||||
import type { ReadLogEntriesOptions } from '../../services/logging';
|
||||
|
||||
export function getDashboardLoggingConfig(): LoggingConfig {
|
||||
return getResolvedLoggingConfig();
|
||||
}
|
||||
|
||||
export function updateDashboardLoggingConfig(updates: Partial<LoggingConfig>): LoggingConfig {
|
||||
const updated = mutateUnifiedConfig((config) => {
|
||||
config.logging = {
|
||||
...getResolvedLoggingConfig(),
|
||||
...config.logging,
|
||||
...updates,
|
||||
};
|
||||
});
|
||||
invalidateLoggingConfigCache();
|
||||
|
||||
return {
|
||||
...getResolvedLoggingConfig(),
|
||||
...updated.logging,
|
||||
};
|
||||
}
|
||||
|
||||
export function listDashboardLogSources() {
|
||||
return readLogSourceSummaries();
|
||||
}
|
||||
|
||||
export function listDashboardLogEntries(options: ReadLogEntriesOptions = {}) {
|
||||
return readLogEntries(options);
|
||||
}
|
||||
+24
-14
@@ -7,12 +7,14 @@
|
||||
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
# Logs
|
||||
logs
|
||||
!src/components/logs/
|
||||
!src/components/logs/**
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user