diff --git a/.env.example b/.env.example index d0872ca2..a4cdb4ac 100644 --- a/.env.example +++ b/.env.example @@ -118,3 +118,7 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # Logging # LOG_LEVEL=info # LOG_FORMAT=text +LOG_TO_FILE=true +# LOG_FILE_PATH=logs/application/app.log +# LOG_MAX_FILE_SIZE=50M +# LOG_RETENTION_DAYS=7 diff --git a/.gitignore b/.gitignore index 3d14318b..1842d3e9 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,4 @@ security-analysis/ # Deploy workflow (contains sensitive VPS credentials) .agent/workflows/deploy.md +clipr/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 759145c7..f379517d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [1.0.3] — 2026-02-19 + +> ### 📊 Logs Dashboard & Real-Time Console Viewer +> +> Unified logs interface with real-time console log viewer, file-based logging via console interception, and server initialization improvements. + +### ✨ Features + +- **Logs Dashboard** — Consolidated 4-tab page at `/dashboard/logs` with Request Logs, Proxy Logs, Audit Logs, and Console tabs +- **Console Log Viewer** — Terminal-style real-time log viewer with color-coded log levels, auto-scroll, search/filtering, level filter, and 5-second polling +- **Console Interceptor** — Monkey-patches `console.log/info/warn/error/debug` at server start to capture all application output as JSON lines to `logs/application/app.log` +- **Log Rotation** — Size-based rotation and retention-based cleanup for log files + +### 🔧 Improvements + +- **Instrumentation consolidation** — Moved `initAuditLog()`, `cleanupExpiredLogs()`, and console interceptor initialization to Next.js `instrumentation.ts` (runs on both dev and prod server start) +- **Structured Logger file output** — `structuredLogger.ts` now also appends JSON log entries to the log file +- **Pino Logger fix** — Fixed broken mix of pino `transport` targets + manual `createWriteStream`; now uses `pino/file` transport targets exclusively with absolute paths + +### 🗂️ Files Added + +| File | Purpose | +| ---------------------------------------------------- | ----------------------------------------------------------------- | +| `src/app/(dashboard)/dashboard/logs/page.tsx` | Tabbed Logs Dashboard page | +| `src/app/(dashboard)/dashboard/logs/AuditLogTab.tsx` | Audit log tab component extracted from standalone page | +| `src/shared/components/ConsoleLogViewer.tsx` | Terminal-style real-time log viewer | +| `src/app/api/logs/console/route.ts` | API endpoint to read log file (filters last 1h, level, component) | +| `src/lib/consoleInterceptor.ts` | Console method monkey-patching for file capture | +| `src/lib/logRotation.ts` | Log rotation by size and cleanup by retention days | + +### 🗂️ Files Modified + +| File | Change | +| --------------------------------------- | ------------------------------------------------------------------------------- | +| `src/shared/components/Sidebar.tsx` | Nav: "Request Logs" → "Logs" with `description` icon | +| `src/shared/components/Breadcrumbs.tsx` | Added breadcrumb labels for `logs`, `audit-log`, `console` | +| `src/instrumentation.ts` | Added console interceptor + audit log init + expired log cleanup | +| `src/server-init.ts` | Added console interceptor import (backup init) | +| `src/shared/utils/logger.ts` | Fixed pino file transport using `pino/file` targets | +| `src/shared/utils/structuredLogger.ts` | Added `appendFileSync` file writing + log file config | +| `.env.example` | Added `LOG_TO_FILE`, `LOG_FILE_PATH`, `LOG_MAX_FILE_SIZE`, `LOG_RETENTION_DAYS` | + +### ⚙️ Configuration + +New environment variables: + +| Variable | Default | Description | +| -------------------- | -------------------------- | ----------------------------- | +| `LOG_TO_FILE` | `true` | Enable/disable file logging | +| `LOG_FILE_PATH` | `logs/application/app.log` | Log file path | +| `LOG_MAX_FILE_SIZE` | `50M` | Max file size before rotation | +| `LOG_RETENTION_DAYS` | `7` | Days to retain old log files | + +--- + ## [1.0.2] — 2026-02-18 > ### 🔒 Security Hardening, Architecture Improvements & UX Polish @@ -232,5 +287,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- [1.1.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.1.0 +[1.0.3]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.3 [1.0.2]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.2 [1.0.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.0 diff --git a/README.de.md b/README.de.md index 888ac357..5e44903e 100644 --- a/README.de.md +++ b/README.de.md @@ -242,7 +242,7 @@ docker compose --profile cli up -d | Image | Tag | Größe | Beschreibung | | ------------------------ | -------- | ------ | ------------------------ | | `diegosouzapw/omniroute` | `latest` | ~250MB | Letztes stabiles Release | -| `diegosouzapw/omniroute` | `1.0.2` | ~250MB | Aktuelle Version | +| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Aktuelle Version | --- @@ -892,7 +892,7 @@ Das vorgeladene „OmniRoute Golden Set" enthält 10 Testfälle: **Verbindungstest zeigt „Invalid" für OpenAI-kompatible Anbieter** - Viele Anbieter stellen den `/models` Endpoint nicht bereit -- OmniRoute v1.0.2+ enthält Fallback-Validierung via Chat Completions +- OmniRoute v1.0.3+ enthält Fallback-Validierung via Chat Completions - Stelle sicher, dass die Base URL den `/v1` Suffix enthält @@ -902,7 +902,7 @@ Das vorgeladene „OmniRoute Golden Set" enthält 10 Testfälle: ## 🛠️ Technologie-Stack - **Runtime**: Node.js 20+ -- **Sprache**: TypeScript 5.9 — **100% TypeScript** in `src/` und `open-sse/` (v1.0.2) +- **Sprache**: TypeScript 5.9 — **100% TypeScript** in `src/` und `open-sse/` (v1.0.3) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 - **Datenbank**: LowDB (JSON) + SQLite (Domain-Status + Proxy-Logs) - **Streaming**: Server-Sent Events (SSE) @@ -957,7 +957,7 @@ Siehe [CONTRIBUTING.md](CONTRIBUTING.md) für detaillierte Richtlinien. ```bash # Release erstellen — npm-Veröffentlichung erfolgt automatisch -gh release create v1.0.2 --title "v1.0.2" --generate-notes +gh release create v1.0.3 --title "v1.0.3" --generate-notes ``` --- diff --git a/README.es.md b/README.es.md index 4cc37b48..b59f5939 100644 --- a/README.es.md +++ b/README.es.md @@ -242,7 +242,7 @@ docker compose --profile cli up -d | Imagen | Tag | Tamaño | Descripción | | ------------------------ | -------- | ------ | ---------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Última versión estable | -| `diegosouzapw/omniroute` | `1.0.2` | ~250MB | Versión actual | +| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Versión actual | --- @@ -892,7 +892,7 @@ El "OmniRoute Golden Set" precargado contiene 10 casos de prueba que cubren: **Prueba de conexión muestra "Invalid" para proveedores compatibles con OpenAI** - Muchos proveedores no exponen el endpoint `/models` -- OmniRoute v1.0.2+ incluye validación vía chat completions como fallback +- OmniRoute v1.0.3+ incluye validación vía chat completions como fallback - Asegúrate de que la URL base incluya el sufijo `/v1` @@ -902,7 +902,7 @@ El "OmniRoute Golden Set" precargado contiene 10 casos de prueba que cubren: ## 🛠️ Stack Tecnológico - **Runtime**: Node.js 20+ -- **Lenguaje**: TypeScript 5.9 — **100% TypeScript** en `src/` y `open-sse/` (v1.0.2) +- **Lenguaje**: TypeScript 5.9 — **100% TypeScript** en `src/` y `open-sse/` (v1.0.3) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 - **Base de Datos**: LowDB (JSON) + SQLite (estado del dominio + logs de proxy) - **Streaming**: Server-Sent Events (SSE) @@ -957,7 +957,7 @@ Consulta [CONTRIBUTING.md](CONTRIBUTING.md) para directrices detalladas. ```bash # Crea un release — la publicación en npm ocurre automáticamente -gh release create v1.0.2 --title "v1.0.2" --generate-notes +gh release create v1.0.3 --title "v1.0.3" --generate-notes ``` --- diff --git a/README.fr.md b/README.fr.md index c9e71bd6..0fa597ac 100644 --- a/README.fr.md +++ b/README.fr.md @@ -242,7 +242,7 @@ docker compose --profile cli up -d | Image | Tag | Taille | Description | | ------------------------ | -------- | ------ | ----------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Dernière version stable | -| `diegosouzapw/omniroute` | `1.0.2` | ~250MB | Version actuelle | +| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Version actuelle | --- @@ -892,7 +892,7 @@ Le « OmniRoute Golden Set » préchargé contient 10 cas de test : **Le test de connexion affiche « Invalid » pour les fournisseurs compatibles OpenAI** - Beaucoup de fournisseurs n'exposent pas le point de terminaison `/models` -- OmniRoute v1.0.2+ inclut une validation de secours via chat completions +- OmniRoute v1.0.3+ inclut une validation de secours via chat completions - Assurez-vous que l'URL de base inclut le suffixe `/v1` @@ -902,7 +902,7 @@ Le « OmniRoute Golden Set » préchargé contient 10 cas de test : ## 🛠️ Stack technologique - **Runtime** : Node.js 20+ -- **Langage** : TypeScript 5.9 — **100% TypeScript** dans `src/` et `open-sse/` (v1.0.2) +- **Langage** : TypeScript 5.9 — **100% TypeScript** dans `src/` et `open-sse/` (v1.0.3) - **Framework** : Next.js 16 + React 19 + Tailwind CSS 4 - **Base de données** : LowDB (JSON) + SQLite (état du domaine + logs proxy) - **Streaming** : Server-Sent Events (SSE) @@ -957,7 +957,7 @@ Consultez [CONTRIBUTING.md](CONTRIBUTING.md) pour les directives détaillées. ```bash # Créer un release — la publication npm est automatique -gh release create v1.0.2 --title "v1.0.2" --generate-notes +gh release create v1.0.3 --title "v1.0.3" --generate-notes ``` --- diff --git a/README.it.md b/README.it.md index 3e9c469b..b8340559 100644 --- a/README.it.md +++ b/README.it.md @@ -242,7 +242,7 @@ docker compose --profile cli up -d | Immagine | Tag | Dimensione | Descrizione | | ------------------------ | -------- | ---------- | ----------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Ultima versione stabile | -| `diegosouzapw/omniroute` | `1.0.2` | ~250MB | Versione attuale | +| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Versione attuale | --- @@ -892,7 +892,7 @@ Il "OmniRoute Golden Set" precaricato contiene 10 casi di test: **Il test di connessione mostra "Invalid" per provider compatibili OpenAI** - Molti provider non espongono l'endpoint `/models` -- OmniRoute v1.0.2+ include validazione fallback tramite chat completions +- OmniRoute v1.0.3+ include validazione fallback tramite chat completions - Assicurati che la URL base includa il suffisso `/v1` @@ -902,7 +902,7 @@ Il "OmniRoute Golden Set" precaricato contiene 10 casi di test: ## 🛠️ Stack Tecnologico - **Runtime**: Node.js 20+ -- **Linguaggio**: TypeScript 5.9 — **100% TypeScript** in `src/` e `open-sse/` (v1.0.2) +- **Linguaggio**: TypeScript 5.9 — **100% TypeScript** in `src/` e `open-sse/` (v1.0.3) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 - **Database**: LowDB (JSON) + SQLite (stato dominio + log proxy) - **Streaming**: Server-Sent Events (SSE) @@ -957,7 +957,7 @@ Consulta [CONTRIBUTING.md](CONTRIBUTING.md) per le linee guida dettagliate. ```bash # Crea un rilascio — la pubblicazione npm avviene automaticamente -gh release create v1.0.2 --title "v1.0.2" --generate-notes +gh release create v1.0.3 --title "v1.0.3" --generate-notes ``` --- diff --git a/README.md b/README.md index e47c71be..d243c8ff 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ docker compose --profile cli up -d | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `1.0.2` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Current version | --- @@ -375,17 +375,19 @@ Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... ### 📊 Observability & Analytics -| Feature | What It Does | -| ---------------------------- | --------------------------------------------------------------- | -| 📝 **Request Logging** | Debug mode with full request/response logs | -| 💾 **SQLite Proxy Logs** | Persistent proxy logs survive server restarts | -| 📊 **Analytics Dashboard** | Recharts-powered: stat cards, model usage chart, provider table | -| 📈 **Progress Tracking** | Opt-in SSE progress events for streaming | -| 🧪 **LLM Evaluations** | Golden set testing with 4 match strategies | -| 🔍 **Request Telemetry** | p50/p95/p99 latency aggregation + X-Request-Id tracing | -| 📋 **Request Logs + Quotas** | Dedicated pages for log browsing and limits/quotas tracking | -| 🏥 **Health Dashboard** | System uptime, circuit breaker states, lockouts, cache stats | -| 💰 **Cost Tracking** | Budget management + per-model pricing configuration | +| Feature | What It Does | +| -------------------------- | ---------------------------------------------------------------------- | +| 📝 **Request Logging** | Debug mode with full request/response logs | +| 💾 **SQLite Proxy Logs** | Persistent proxy logs survive server restarts | +| 📊 **Analytics Dashboard** | Recharts-powered: stat cards, model usage chart, provider table | +| 📈 **Progress Tracking** | Opt-in SSE progress events for streaming | +| 🧪 **LLM Evaluations** | Golden set testing with 4 match strategies | +| 🔍 **Request Telemetry** | p50/p95/p99 latency aggregation + X-Request-Id tracing | +| 📋 **Logs Dashboard** | Unified 4-tab page: Request Logs, Proxy Logs, Audit Logs, Console | +| 🖥️ **Console Log Viewer** | Real-time terminal-style viewer with level filter, search, auto-scroll | +| 📑 **File-Based Logging** | Console interceptor captures all output to JSON log file with rotation | +| 🏥 **Health Dashboard** | System uptime, circuit breaker states, lockouts, cache stats | +| 💰 **Cost Tracking** | Budget management + per-model pricing configuration | ### ☁️ Deployment & Sync @@ -903,7 +905,7 @@ The pre-loaded "OmniRoute Golden Set" contains 10 test cases covering: **Connection test shows "Invalid" for OpenAI-compatible providers** - Many providers don't expose a `/models` endpoint -- OmniRoute v1.0.2+ includes fallback validation via chat completions +- OmniRoute v1.0.3+ includes fallback validation via chat completions - Ensure base URL includes `/v1` suffix @@ -913,7 +915,7 @@ The pre-loaded "OmniRoute Golden Set" contains 10 test cases covering: ## 🛠️ Tech Stack - **Runtime**: Node.js 20+ -- **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (v1.0.2) +- **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (v1.0.3) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 - **Database**: LowDB (JSON) + SQLite (domain state + proxy logs) - **Streaming**: Server-Sent Events (SSE) @@ -1014,7 +1016,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. ```bash # Create a release — npm publish happens automatically -gh release create v1.0.2 --title "v1.0.2" --generate-notes +gh release create v1.0.3 --title "v1.0.3" --generate-notes ``` --- diff --git a/README.pt-BR.md b/README.pt-BR.md index df53ff0c..90228bcd 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -242,7 +242,7 @@ docker compose --profile cli up -d | Imagem | Tag | Tamanho | Descrição | | ------------------------ | -------- | ------- | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Última versão estável | -| `diegosouzapw/omniroute` | `1.0.2` | ~250MB | Versão atual | +| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Versão atual | --- @@ -899,7 +899,7 @@ O "OmniRoute Golden Set" pré-carregado contém 10 casos de teste cobrindo: **Teste de conexão mostra "Invalid" para provedores compatíveis com OpenAI** - Muitos provedores não expõem endpoint `/models` -- OmniRoute v1.0.2+ inclui validação via chat completions como fallback +- OmniRoute v1.0.3+ inclui validação via chat completions como fallback - Certifique-se de que a base URL inclui sufixo `/v1` @@ -909,7 +909,7 @@ O "OmniRoute Golden Set" pré-carregado contém 10 casos de teste cobrindo: ## 🛠️ Stack Tecnológico - **Runtime**: Node.js 20+ -- **Linguagem**: TypeScript 5.9 — **100% TypeScript** em `src/` e `open-sse/` (v1.0.2) +- **Linguagem**: TypeScript 5.9 — **100% TypeScript** em `src/` e `open-sse/` (v1.0.3) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 - **Banco de Dados**: LowDB (JSON) + SQLite (estado do domínio + logs de proxy) - **Streaming**: Server-Sent Events (SSE) @@ -1010,7 +1010,7 @@ Veja [CONTRIBUTING.md](CONTRIBUTING.md) para diretrizes detalhadas. ```bash # Crie um release — publicação no npm acontece automaticamente -gh release create v1.0.2 --title "v1.0.2" --generate-notes +gh release create v1.0.3 --title "v1.0.3" --generate-notes ``` --- diff --git a/README.ru.md b/README.ru.md index 548eb583..bdb41edd 100644 --- a/README.ru.md +++ b/README.ru.md @@ -242,7 +242,7 @@ docker compose --profile cli up -d | Образ | Тег | Размер | Описание | | ------------------------ | -------- | ------ | -------------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Последний стабильный релиз | -| `diegosouzapw/omniroute` | `1.0.2` | ~250MB | Текущая версия | +| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | Текущая версия | --- @@ -892,7 +892,7 @@ OmniRoute включает встроенный фреймворк оценки **Тест подключения показывает «Invalid» для OpenAI-совместимых провайдеров** - Многие провайдеры не предоставляют endpoint `/models` -- OmniRoute v1.0.2+ включает fallback-валидацию через chat completions +- OmniRoute v1.0.3+ включает fallback-валидацию через chat completions - Убедитесь что base URL содержит суффикс `/v1` @@ -902,7 +902,7 @@ OmniRoute включает встроенный фреймворк оценки ## 🛠️ Технологический стек - **Runtime**: Node.js 20+ -- **Язык**: TypeScript 5.9 — **100% TypeScript** в `src/` и `open-sse/` (v1.0.2) +- **Язык**: TypeScript 5.9 — **100% TypeScript** в `src/` и `open-sse/` (v1.0.3) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 - **База данных**: LowDB (JSON) + SQLite (состояние домена + proxy-логи) - **Стриминг**: Server-Sent Events (SSE) @@ -957,7 +957,7 @@ OmniRoute включает встроенный фреймворк оценки ```bash # Создайте релиз — публикация в npm происходит автоматически -gh release create v1.0.2 --title "v1.0.2" --generate-notes +gh release create v1.0.3 --title "v1.0.3" --generate-notes ``` --- diff --git a/README.zh-CN.md b/README.zh-CN.md index cdec51de..ebd270cf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -242,7 +242,7 @@ docker compose --profile cli up -d | 镜像 | 标签 | 大小 | 描述 | | ------------------------ | -------- | ------ | ---------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | 最新稳定版 | -| `diegosouzapw/omniroute` | `1.0.2` | ~250MB | 当前版本 | +| `diegosouzapw/omniroute` | `1.0.3` | ~250MB | 当前版本 | --- @@ -892,7 +892,7 @@ OmniRoute 包含内置评估框架,用于针对黄金集测试 LLM 响应质 **兼容 OpenAI 的提供商连接测试显示 "Invalid"** - 许多提供商不暴露 `/models` 端点 -- OmniRoute v1.0.2+ 包含通过 chat completions 的回退验证 +- OmniRoute v1.0.3+ 包含通过 chat completions 的回退验证 - 确保 base URL 包含 `/v1` 后缀 @@ -902,7 +902,7 @@ OmniRoute 包含内置评估框架,用于针对黄金集测试 LLM 响应质 ## 🛠️ 技术栈 - **运行时**: Node.js 20+ -- **语言**: TypeScript 5.9 — `src/` 和 `open-sse/` 中 **100% TypeScript**(v1.0.2) +- **语言**: TypeScript 5.9 — `src/` 和 `open-sse/` 中 **100% TypeScript**(v1.0.3) - **框架**: Next.js 16 + React 19 + Tailwind CSS 4 - **数据库**: LowDB (JSON) + SQLite(领域状态 + 代理日志) - **流式传输**: Server-Sent Events (SSE) @@ -957,7 +957,7 @@ OmniRoute 包含内置评估框架,用于针对黄金集测试 LLM 响应质 ```bash # 创建发布 — npm 发布自动完成 -gh release create v1.0.2 --title "v1.0.2" --generate-notes +gh release create v1.0.3 --title "v1.0.3" --generate-notes ``` --- diff --git a/clipr/9router b/clipr/9router new file mode 160000 index 00000000..bc91be73 --- /dev/null +++ b/clipr/9router @@ -0,0 +1 @@ +Subproject commit bc91be7305813f6b98f3c8267d04f4717802adf6 diff --git a/clipr/CLIProxyAPI b/clipr/CLIProxyAPI new file mode 160000 index 00000000..068630db --- /dev/null +++ b/clipr/CLIProxyAPI @@ -0,0 +1 @@ +Subproject commit 068630dbd08b950c30949d4f0621a11729627c05 diff --git a/package-lock.json b/package-lock.json index c41762c1..6cecc015 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "1.0.2", + "version": "1.0.3", "license": "MIT", "workspaces": [ "open-sse" diff --git a/package.json b/package.json index 504c1c58..da535e86 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "1.0.2", + "version": "1.0.3", "description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.", "type": "module", "bin": { diff --git a/src/app/(dashboard)/dashboard/logs/AuditLogTab.tsx b/src/app/(dashboard)/dashboard/logs/AuditLogTab.tsx new file mode 100644 index 00000000..4adf9a1c --- /dev/null +++ b/src/app/(dashboard)/dashboard/logs/AuditLogTab.tsx @@ -0,0 +1,232 @@ +"use client"; + +/** + * Audit Log Tab — Embedded version of the audit-log page for the Logs dashboard. + * Fetches from /api/compliance/audit-log with filter support. + */ + +import { useState, useEffect, useCallback } from "react"; + +interface AuditEntry { + id: number; + timestamp: string; + action: string; + actor: string; + target: string | null; + details: any; + ip_address: string | null; +} + +const PAGE_SIZE = 25; + +export default function AuditLogTab() { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [actionFilter, setActionFilter] = useState(""); + const [actorFilter, setActorFilter] = useState(""); + const [offset, setOffset] = useState(0); + const [hasMore, setHasMore] = useState(false); + + const fetchEntries = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams(); + if (actionFilter) params.set("action", actionFilter); + if (actorFilter) params.set("actor", actorFilter); + params.set("limit", String(PAGE_SIZE + 1)); + params.set("offset", String(offset)); + + const res = await fetch(`/api/compliance/audit-log?${params.toString()}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data: AuditEntry[] = await res.json(); + + setHasMore(data.length > PAGE_SIZE); + setEntries(data.slice(0, PAGE_SIZE)); + } catch (err: any) { + setError(err.message || "Failed to fetch audit log"); + } finally { + setLoading(false); + } + }, [actionFilter, actorFilter, offset]); + + useEffect(() => { + fetchEntries(); + }, [fetchEntries]); + + const handleSearch = () => { + setOffset(0); + fetchEntries(); + }; + + const formatTimestamp = (ts: string) => { + try { + return new Date(ts).toLocaleString(); + } catch { + return ts; + } + }; + + const actionBadgeColor = (action: string) => { + if (action.includes("delete") || action.includes("remove")) + return "bg-red-500/15 text-red-400 border-red-500/20"; + if (action.includes("create") || action.includes("add")) + return "bg-green-500/15 text-green-400 border-green-500/20"; + if (action.includes("update") || action.includes("change")) + return "bg-blue-500/15 text-blue-400 border-blue-500/20"; + if (action.includes("login") || action.includes("auth")) + return "bg-purple-500/15 text-purple-400 border-purple-500/20"; + return "bg-gray-500/15 text-gray-400 border-gray-500/20"; + }; + + return ( +
+ {/* Header */} +
+
+

Audit Log

+

+ Administrative actions and security events +

+
+ +
+ + {/* Filters */} +
+ setActionFilter(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + aria-label="Filter by action type" + className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]" + /> + setActorFilter(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + aria-label="Filter by actor" + className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]" + /> + +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Table */} +
+ + + + + + + + + + + + + {entries.length === 0 && !loading ? ( + + + + ) : ( + entries.map((entry) => ( + + + + + + + + + )) + )} + +
+ Timestamp + + Action + + Actor + + Target + + Details + IP
+ No audit log entries found +
+ {formatTimestamp(entry.timestamp)} + + + {entry.action} + + {entry.actor} + {entry.target || "—"} + + {entry.details ? JSON.stringify(entry.details) : "—"} + + {entry.ip_address || "—"} +
+
+ + {/* Pagination */} +
+

+ Showing {entries.length} entries (offset {offset}) +

+
+ + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/logs/page.tsx b/src/app/(dashboard)/dashboard/logs/page.tsx new file mode 100644 index 00000000..99246b37 --- /dev/null +++ b/src/app/(dashboard)/dashboard/logs/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useState } from "react"; +import { RequestLoggerV2, ProxyLogger, SegmentedControl } from "@/shared/components"; +import ConsoleLogViewer from "@/shared/components/ConsoleLogViewer"; +import AuditLogTab from "./AuditLogTab"; + +export default function LogsPage() { + const [activeTab, setActiveTab] = useState("request-logs"); + + return ( +
+ + + {/* Content */} + {activeTab === "request-logs" && } + {activeTab === "proxy-logs" && } + {activeTab === "audit-logs" && } + {activeTab === "console" && } +
+ ); +} diff --git a/src/app/api/logs/console/route.ts b/src/app/api/logs/console/route.ts new file mode 100644 index 00000000..cf120588 --- /dev/null +++ b/src/app/api/logs/console/route.ts @@ -0,0 +1,115 @@ +/** + * Console Log API — GET /api/logs/console + * + * Reads the application log file and returns entries from the last 1 hour. + * Supports filtering by level and limiting the number of entries. + * + * Query params: + * - level: minimum log level (debug|info|warn|error) — default: all + * - limit: max entries to return — default: 500 + * - component: filter by component/module name + */ + +import { NextRequest, NextResponse } from "next/server"; +import { readFileSync, existsSync } from "fs"; +import { join } from "path"; + +const LEVEL_ORDER: Record = { + trace: 5, + debug: 10, + info: 20, + warn: 30, + error: 40, + fatal: 50, +}; + +// Map pino numeric levels to string levels +const NUMERIC_LEVEL_MAP: Record = { + 10: "trace", + 20: "info", + 30: "warn", + 40: "error", + 50: "fatal", + 60: "fatal", +}; + +function getLogFilePath(): string { + return process.env.LOG_FILE_PATH || join(process.cwd(), "logs", "application", "app.log"); +} + +function parseLevel(raw: string | number): string { + if (typeof raw === "number") { + return NUMERIC_LEVEL_MAP[raw] || "info"; + } + return String(raw).toLowerCase(); +} + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const levelFilter = searchParams.get("level") || "all"; + const limit = Math.min(parseInt(searchParams.get("limit") || "500", 10), 2000); + const componentFilter = searchParams.get("component") || ""; + + const logPath = getLogFilePath(); + + if (!existsSync(logPath)) { + return NextResponse.json([], { status: 200 }); + } + + const raw = readFileSync(logPath, "utf-8"); + const lines = raw.trim().split("\n").filter(Boolean); + + const oneHourAgo = Date.now() - 60 * 60 * 1000; + const minLevel = LEVEL_ORDER[levelFilter] || 0; + + const entries: any[] = []; + + for (const line of lines) { + try { + const entry = JSON.parse(line); + + // Filter by time (last 1 hour) + const ts = entry.time || entry.timestamp; + if (ts) { + const entryTime = new Date(ts).getTime(); + if (entryTime < oneHourAgo) continue; + } + + // Normalize level + entry.level = parseLevel(entry.level); + + // Filter by level + const entryLevelNum = LEVEL_ORDER[entry.level] || 0; + if (minLevel > 0 && entryLevelNum < minLevel) continue; + + // Filter by component + if (componentFilter) { + const comp = entry.component || entry.module || ""; + if (!comp.toLowerCase().includes(componentFilter.toLowerCase())) continue; + } + + // Normalize timestamp field + if (entry.time && !entry.timestamp) { + entry.timestamp = entry.time; + } + + entries.push(entry); + } catch { + // Skip unparseable lines + } + } + + // Return last N entries (most recent) + const result = entries.slice(-limit); + + return NextResponse.json(result, { + status: 200, + headers: { + "Cache-Control": "no-store, no-cache, must-revalidate", + }, + }); + } catch (err: any) { + return NextResponse.json({ error: err.message || "Failed to read logs" }, { status: 500 }); + } +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 7d35c420..9f2a5ee5 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -2,7 +2,8 @@ * Next.js Instrumentation Hook * * Called once when the server starts (both dev and production). - * Used to initialize graceful shutdown handlers. + * Used to initialize graceful shutdown handlers, console log capture, + * and compliance features (audit log table, expired log cleanup). * * @see https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation */ @@ -10,7 +11,26 @@ export async function register() { // Only run on the server (not during build or in Edge runtime) if (process.env.NEXT_RUNTIME === "nodejs") { + // Console log file capture (must be first — before any logging occurs) + const { initConsoleInterceptor } = await import("@/lib/consoleInterceptor"); + initConsoleInterceptor(); + const { initGracefulShutdown } = await import("@/lib/gracefulShutdown"); initGracefulShutdown(); + + // Compliance: Initialize audit_log table + cleanup expired logs + try { + const { initAuditLog, cleanupExpiredLogs } = await import("@/lib/compliance/index"); + initAuditLog(); + console.log("[COMPLIANCE] Audit log table initialized"); + + const cleanup = cleanupExpiredLogs(); + if (cleanup.deletedUsage || cleanup.deletedCallLogs || cleanup.deletedAuditLogs) { + console.log("[COMPLIANCE] Expired log cleanup:", cleanup); + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[COMPLIANCE] Could not initialize audit log:", msg); + } } } diff --git a/src/lib/consoleInterceptor.ts b/src/lib/consoleInterceptor.ts new file mode 100644 index 00000000..d6ea93b8 --- /dev/null +++ b/src/lib/consoleInterceptor.ts @@ -0,0 +1,127 @@ +/** + * Console Log Interceptor — captures console output to a log file. + * + * Monkey-patches console.log, console.info, console.warn, console.error, + * and console.debug to also append JSON log entries to a file. This allows + * the Console Log Viewer to display application logs in real-time. + * + * Call initConsoleInterceptor() once at server startup (before any logging). + * + * @module lib/consoleInterceptor + */ + +import { appendFileSync, existsSync, mkdirSync } from "fs"; +import { dirname, resolve } from "path"; + +const logToFile = process.env.LOG_TO_FILE !== "false"; +const logFilePath = resolve(process.env.LOG_FILE_PATH || "logs/application/app.log"); + +let initialized = false; + +/** + * Map console method names to log levels. + */ +const LEVEL_MAP: Record = { + debug: "debug", + log: "info", + info: "info", + warn: "warn", + error: "error", +}; + +/** + * Ensure the log directory exists. + */ +function ensureDir() { + const dir = dirname(logFilePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } +} + +/** + * Try to extract component name from message patterns like [COMPONENT] or [component]. + */ +function extractComponent(msg: string): string { + const match = msg.match(/^\[([^\]]+)\]/); + return match ? match[1] : "app"; +} + +/** + * Convert arguments to a string message, handling objects and errors. + */ +function argsToMessage(args: unknown[]): string { + return args + .map((arg) => { + if (arg instanceof Error) return `${arg.message}\n${arg.stack || ""}`; + if (typeof arg === "object" && arg !== null) { + try { + return JSON.stringify(arg); + } catch { + return String(arg); + } + } + return String(arg); + }) + .join(" "); +} + +/** + * Append a JSON log entry to the log file. + */ +function writeEntry(level: string, args: unknown[]) { + try { + const message = argsToMessage(args); + const entry = { + timestamp: new Date().toISOString(), + level, + component: extractComponent(message), + message, + }; + appendFileSync(logFilePath, JSON.stringify(entry) + "\n"); + } catch { + // Silently fail — never break the app over log writing + } +} + +/** + * Initialize the console interceptor. + * Patches console.log, console.info, console.warn, console.error, console.debug + * to also write to the log file. + * + * Safe to call multiple times — only initializes once. + */ +export function initConsoleInterceptor(): void { + if (!logToFile || initialized) return; + + try { + ensureDir(); + } catch { + // Can't create log dir — skip interception + return; + } + + initialized = true; + + // Save original methods + const originalMethods = { + log: console.log.bind(console), + info: console.info.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + debug: console.debug.bind(console), + }; + + // Patch each console method + for (const [method, level] of Object.entries(LEVEL_MAP)) { + const original = originalMethods[method as keyof typeof originalMethods]; + if (!original) continue; + + (console as unknown as Record)[method] = (...args: unknown[]) => { + // Call original console method + original(...args); + // Also write to file + writeEntry(level, args); + }; + } +} diff --git a/src/lib/logRotation.ts b/src/lib/logRotation.ts new file mode 100644 index 00000000..80035199 --- /dev/null +++ b/src/lib/logRotation.ts @@ -0,0 +1,136 @@ +/** + * Log Rotation & Cleanup — manages application log file rotation. + * + * Handles: + * - Rotating log files when they exceed max size + * - Cleaning up old log files past retention period + * - Creating the log directory on startup + * + * Configuration via env vars: + * - LOG_TO_FILE: enable file logging (default: true) + * - LOG_FILE_PATH: path to log file (default: logs/application/app.log) + * - LOG_MAX_FILE_SIZE: max file size before rotation (default: 50MB) + * - LOG_RETENTION_DAYS: days to keep old logs (default: 7) + */ + +import { existsSync, mkdirSync, statSync, renameSync, readdirSync, unlinkSync } from "fs"; +import { dirname, join, basename, extname } from "path"; + +const DEFAULT_LOG_PATH = "logs/application/app.log"; +const DEFAULT_MAX_SIZE = 50 * 1024 * 1024; // 50MB +const DEFAULT_RETENTION_DAYS = 7; + +function parseFileSize(raw: string | undefined): number { + if (!raw) return DEFAULT_MAX_SIZE; + const match = raw.match(/^(\d+)\s*(k|m|g|kb|mb|gb)?$/i); + if (!match) return DEFAULT_MAX_SIZE; + const num = parseInt(match[1], 10); + const unit = (match[2] || "").toLowerCase(); + switch (unit) { + case "k": + case "kb": + return num * 1024; + case "m": + case "mb": + return num * 1024 * 1024; + case "g": + case "gb": + return num * 1024 * 1024 * 1024; + default: + return num; + } +} + +export function getLogConfig() { + const logToFile = process.env.LOG_TO_FILE !== "false"; + const logFilePath = process.env.LOG_FILE_PATH || join(process.cwd(), DEFAULT_LOG_PATH); + const maxFileSize = parseFileSize(process.env.LOG_MAX_FILE_SIZE); + const retentionDays = parseInt( + process.env.LOG_RETENTION_DAYS || String(DEFAULT_RETENTION_DAYS), + 10 + ); + + return { logToFile, logFilePath, maxFileSize, retentionDays }; +} + +/** + * Ensure the log directory exists. + */ +export function ensureLogDir(logFilePath: string): void { + const dir = dirname(logFilePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } +} + +/** + * Rotate the log file if it exceeds the max size. + * Renames current file to app.YYYY-MM-DD_HHmmss.log + */ +export function rotateIfNeeded(logFilePath: string, maxFileSize: number): void { + try { + if (!existsSync(logFilePath)) return; + const stats = statSync(logFilePath); + if (stats.size < maxFileSize) return; + + const dir = dirname(logFilePath); + const ext = extname(logFilePath); + const base = basename(logFilePath, ext); + const now = new Date(); + const ts = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String( + now.getDate() + ).padStart(2, "0")}_${String(now.getHours()).padStart(2, "0")}${String( + now.getMinutes() + ).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`; + + const rotatedPath = join(dir, `${base}.${ts}${ext}`); + renameSync(logFilePath, rotatedPath); + } catch { + // If rotation fails, continue writing to the same file + } +} + +/** + * Remove log files older than the retention period. + */ +export function cleanupOldLogs(logFilePath: string, retentionDays: number): void { + try { + const dir = dirname(logFilePath); + if (!existsSync(dir)) return; + + const ext = extname(logFilePath); + const base = basename(logFilePath, ext); + const files = readdirSync(dir); + const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000; + + for (const file of files) { + // Match rotated files like app.2026-02-19_030000.log + if (file.startsWith(base + ".") && file.endsWith(ext) && file !== basename(logFilePath)) { + const filePath = join(dir, file); + try { + const stats = statSync(filePath); + if (stats.mtimeMs < cutoff) { + unlinkSync(filePath); + } + } catch { + // Skip files we can't stat + } + } + } + } catch { + // Cleanup is best-effort + } +} + +/** + * Initialize log rotation — call once at application startup. + * Creates directories, rotates if needed, and cleans up old files. + */ +export function initLogRotation(): void { + const config = getLogConfig(); + if (!config.logToFile) return; + + ensureLogDir(config.logFilePath); + rotateIfNeeded(config.logFilePath, config.maxFileSize); + cleanupOldLogs(config.logFilePath, config.retentionDays); +} diff --git a/src/server-init.ts b/src/server-init.ts index 8d60d982..10a443cf 100644 --- a/src/server-init.ts +++ b/src/server-init.ts @@ -2,8 +2,12 @@ import initializeCloudSync from "./shared/services/initializeCloudSync"; import { enforceSecrets } from "./shared/utils/secretsValidator"; import { initAuditLog, cleanupExpiredLogs, logAuditEvent } from "./lib/compliance/index"; +import { initConsoleInterceptor } from "./lib/consoleInterceptor"; async function startServer() { + // Console interceptor: capture all console output to log file (must be first) + initConsoleInterceptor(); + // FASE-01: Validate required secrets before anything else (fail-fast) enforceSecrets(); diff --git a/src/shared/components/Breadcrumbs.tsx b/src/shared/components/Breadcrumbs.tsx index f8eaccf6..e22b2a03 100644 --- a/src/shared/components/Breadcrumbs.tsx +++ b/src/shared/components/Breadcrumbs.tsx @@ -19,7 +19,9 @@ const PATH_LABELS = { providers: "Providers", combos: "Combos", settings: "Settings", - usage: "Usage", + logs: "Logs", + "audit-log": "Audit Log", + console: "Console", logger: "Logger", translator: "Translator", playground: "Playground", @@ -27,7 +29,6 @@ const PATH_LABELS = { edit: "Edit", keys: "API Keys", models: "Models", - logs: "Logs", }; /** @@ -85,8 +86,12 @@ export default function Breadcrumbs() { textDecoration: "none", transition: "color 0.15s", }} - onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = "var(--accent, #818cf8)")} - onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = "var(--text-secondary, #888)")} + onMouseEnter={(e) => + ((e.currentTarget as HTMLElement).style.color = "var(--accent, #818cf8)") + } + onMouseLeave={(e) => + ((e.currentTarget as HTMLElement).style.color = "var(--text-secondary, #888)") + } > {crumb.label} diff --git a/src/shared/components/ConsoleLogViewer.tsx b/src/shared/components/ConsoleLogViewer.tsx new file mode 100644 index 00000000..ef0aa1ab --- /dev/null +++ b/src/shared/components/ConsoleLogViewer.tsx @@ -0,0 +1,302 @@ +"use client"; + +/** + * Console Log Viewer — Real-time application log viewer. + * + * Displays structured application logs from the server with a terminal-like UI. + * Polls the backend API every 5 seconds. Shows logs from the last 1 hour. + * Supports level filtering, text search, auto-scroll, and copy-to-clipboard. + */ + +import { useState, useEffect, useRef, useCallback } from "react"; + +interface LogEntry { + timestamp: string; + level: string; + component?: string; + module?: string; + message?: string; + msg?: string; + correlationId?: string; + [key: string]: unknown; +} + +const LEVEL_COLORS: Record = { + debug: "text-gray-400", + trace: "text-gray-500", + info: "text-cyan-400", + warn: "text-yellow-400", + error: "text-red-400", + fatal: "text-fuchsia-400", +}; + +const LEVEL_BG: Record = { + debug: "bg-gray-500/10 border-gray-500/20", + trace: "bg-gray-500/10 border-gray-500/20", + info: "bg-cyan-500/10 border-cyan-500/20", + warn: "bg-yellow-500/10 border-yellow-500/20", + error: "bg-red-500/10 border-red-500/20", + fatal: "bg-fuchsia-500/10 border-fuchsia-500/20", +}; + +const POLL_INTERVAL = 5000; // 5 seconds + +export default function ConsoleLogViewer() { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [levelFilter, setLevelFilter] = useState("all"); + const [searchText, setSearchText] = useState(""); + const [autoScroll, setAutoScroll] = useState(true); + const [lastUpdated, setLastUpdated] = useState(null); + const [copiedIdx, setCopiedIdx] = useState(null); + const scrollRef = useRef(null); + + const fetchLogs = useCallback(async () => { + try { + const params = new URLSearchParams(); + if (levelFilter !== "all") params.set("level", levelFilter); + params.set("limit", "500"); + + const res = await fetch(`/api/logs/console?${params.toString()}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data: LogEntry[] = await res.json(); + + setLogs(data); + setLastUpdated(new Date()); + setError(null); + } catch (err: any) { + setError(err.message || "Failed to fetch logs"); + } finally { + setLoading(false); + } + }, [levelFilter]); + + // Initial fetch + polling + useEffect(() => { + fetchLogs(); + const interval = setInterval(fetchLogs, POLL_INTERVAL); + return () => clearInterval(interval); + }, [fetchLogs]); + + // Auto-scroll to bottom on new logs + useEffect(() => { + if (autoScroll && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [logs, autoScroll]); + + const handleCopy = (entry: LogEntry, idx: number) => { + const text = JSON.stringify(entry, null, 2); + navigator.clipboard.writeText(text).then(() => { + setCopiedIdx(idx); + setTimeout(() => setCopiedIdx(null), 2000); + }); + }; + + const formatTime = (ts: string) => { + try { + const d = new Date(ts); + return d.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, + }); + } catch { + return ts; + } + }; + + const getText = (entry: LogEntry) => entry.msg || entry.message || ""; + const getComponent = (entry: LogEntry) => entry.component || entry.module || ""; + + // Apply text search filter + const filteredLogs = searchText + ? logs.filter((entry) => { + const full = JSON.stringify(entry).toLowerCase(); + return full.includes(searchText.toLowerCase()); + }) + : logs; + + return ( +
+ {/* Toolbar */} +
+ {/* Level filter */} + + + {/* Search */} + setSearchText(e.target.value)} + aria-label="Search log entries" + className="flex-1 min-w-[200px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]" + /> + + {/* Auto-scroll toggle */} + + + {/* Refresh */} + + + {/* Status */} +
+ + {filteredLogs.length} entries + + Last 1h + {lastUpdated && ( + <> + + Updated {lastUpdated.toLocaleTimeString()} + + )} +
+
+ + {/* Error */} + {error && ( +
+ error + {error} + + — Make sure the application is writing logs to file (LOG_TO_FILE=true) + +
+ )} + + {/* Console output */} +
+ {/* Header bar */} +
+
+
+
+ OmniRoute — Application Console +
+ + {/* Log entries */} +
+ {filteredLogs.length === 0 && !loading ? ( +
+ + terminal + +

No log entries found

+

+ Ensure LOG_TO_FILE=true is set in your .env file +

+
+ ) : ( + filteredLogs.map((entry, idx) => { + const level = (entry.level || "info").toLowerCase(); + const colorClass = LEVEL_COLORS[level] || LEVEL_COLORS.info; + const bgClass = LEVEL_BG[level] || ""; + const comp = getComponent(entry); + const msg = getText(entry); + + return ( +
+ {/* Timestamp */} + + {formatTime(entry.timestamp)} + + + {/* Level badge */} + + {level.padEnd(5)} + + + {/* Component */} + {comp && [{comp}]} + + {/* Message */} + + {msg} + {/* Extra meta */} + {entry.correlationId && ( + + cid:{entry.correlationId.slice(0, 8)} + + )} + + + {/* Copy button */} + +
+ ); + }) + )} + + {loading && filteredLogs.length === 0 && ( +
+ + progress_activity + + Loading logs... +
+ )} +
+
+
+ ); +} diff --git a/src/shared/components/Sidebar.tsx b/src/shared/components/Sidebar.tsx index 9140d326..5bbbe1eb 100644 --- a/src/shared/components/Sidebar.tsx +++ b/src/shared/components/Sidebar.tsx @@ -16,7 +16,7 @@ const navItems = [ { href: "/dashboard/endpoint", label: "Endpoint", icon: "api" }, { href: "/dashboard/providers", label: "Providers", icon: "dns" }, { href: "/dashboard/combos", label: "Combos", icon: "layers" }, - { href: "/dashboard/usage", label: "Request Logs", icon: "receipt_long" }, + { href: "/dashboard/logs", label: "Logs", icon: "description" }, { href: "/dashboard/costs", label: "Costs", icon: "account_balance_wallet" }, { href: "/dashboard/analytics", label: "Analytics", icon: "analytics" }, { href: "/dashboard/limits", label: "Limits & Quotas", icon: "tune" }, diff --git a/src/shared/utils/logger.ts b/src/shared/utils/logger.ts index a18a65e0..7df7bfcf 100644 --- a/src/shared/utils/logger.ts +++ b/src/shared/utils/logger.ts @@ -9,25 +9,99 @@ * * In development, output is pretty-printed via pino-pretty. * In production, output is structured JSON for log aggregation. + * + * When LOG_TO_FILE is enabled (default: true), logs are also written + * as JSON lines to the file specified by LOG_FILE_PATH. */ import pino from "pino"; +import { resolve } from "path"; +import { getLogConfig, initLogRotation } from "@/lib/logRotation"; const isDev = process.env.NODE_ENV !== "production"; -const baseConfig = { +const baseConfig: pino.LoggerOptions = { level: process.env.LOG_LEVEL || (isDev ? "debug" : "info"), base: { service: "omniroute" }, timestamp: pino.stdTimeFunctions.isoTime, formatters: { - level(label) { + level(label: string) { return { level: label }; }, }, }; -// In development, use pino-pretty for human-readable output -const devTransport = isDev - ? { +/** + * Build the logger with optional file transport. + * Uses pino transport targets for all destinations. + */ +function buildLogger(): pino.Logger { + const logConfig = getLogConfig(); + const logLevel = (baseConfig.level as string) || "info"; + + // If file logging is enabled, set up dual transport (stdout + file) + if (logConfig.logToFile) { + try { + // Initialize log directory and rotation + initLogRotation(); + + // Resolve to absolute path for pino worker threads + const absLogPath = resolve(logConfig.logFilePath); + + if (isDev) { + // Dev: pino-pretty → stdout, JSON → file + return pino({ + ...baseConfig, + transport: { + targets: [ + { + target: "pino-pretty", + options: { + colorize: true, + translateTime: "HH:MM:ss.l", + ignore: "pid,hostname,service", + messageFormat: "[{module}] {msg}", + destination: 1, + }, + level: logLevel, + }, + { + target: "pino/file", + options: { destination: absLogPath, mkdir: true }, + level: logLevel, + }, + ], + }, + }); + } + + // Production: JSON → stdout + JSON → file + return pino({ + ...baseConfig, + transport: { + targets: [ + { + target: "pino/file", + options: { destination: 1 }, // stdout + level: logLevel, + }, + { + target: "pino/file", + options: { destination: absLogPath, mkdir: true }, + level: logLevel, + }, + ], + }, + }); + } catch { + // If file setup fails, fall back to console-only logging + console.warn("[logger] Failed to set up file transport, falling back to console only"); + } + } + + // Console-only (no file logging) + if (isDev) { + return pino({ + ...baseConfig, transport: { target: "pino-pretty", options: { @@ -37,17 +111,20 @@ const devTransport = isDev messageFormat: "[{module}] {msg}", }, }, - } - : {}; + }); + } -export const logger = pino({ ...baseConfig, ...devTransport }); + return pino(baseConfig); +} + +export const logger = buildLogger(); /** * Create a child logger with a module tag. * @param {string} module - Module name for log context (e.g., "proxy", "db", "sse") * @returns {pino.Logger} */ -export function createLogger(module) { +export function createLogger(module: string) { return logger.child({ module }); } diff --git a/src/shared/utils/structuredLogger.ts b/src/shared/utils/structuredLogger.ts index 84f77701..03b4e98e 100644 --- a/src/shared/utils/structuredLogger.ts +++ b/src/shared/utils/structuredLogger.ts @@ -5,10 +5,15 @@ * and human-readable output for development. Replaces scattered console.log * calls with consistent, parseable log entries. * + * When LOG_TO_FILE is enabled, log entries are also appended as JSON lines + * to the application log file for the Console Log Viewer. + * * @module shared/utils/structuredLogger */ import { getCorrelationId } from "../middleware/correlationId"; +import { appendFileSync, existsSync, mkdirSync } from "fs"; +import { dirname, resolve } from "path"; const LOG_LEVELS: Record = { debug: 10, @@ -21,7 +26,40 @@ const LOG_LEVELS: Record = { const currentLevel = LOG_LEVELS[process.env.LOG_LEVEL?.toLowerCase() || ""] || LOG_LEVELS.info; const isProduction = process.env.NODE_ENV === "production"; -function formatEntry(level: string, component: string, message: string, meta?: Record) { +// File logging configuration +const logToFile = process.env.LOG_TO_FILE !== "false"; +const logFilePath = resolve(process.env.LOG_FILE_PATH || "logs/application/app.log"); + +// Ensure log directory exists once at module load +if (logToFile) { + try { + const dir = dirname(logFilePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + } catch { + // silently ignore — will retry on each write + } +} + +/** + * Append a JSON log line to the log file (non-blocking best-effort). + */ +function writeToFile(entry: Record) { + if (!logToFile) return; + try { + appendFileSync(logFilePath, JSON.stringify(entry) + "\n"); + } catch { + // Silently fail — file logging should never break the app + } +} + +function formatEntry( + level: string, + component: string, + message: string, + meta?: Record +) { const entry: Record = { timestamp: new Date().toISOString(), level, @@ -46,30 +84,60 @@ function formatEntry(level: string, component: string, message: string, meta?: R return `[${entry.timestamp}] ${level.toUpperCase().padEnd(5)} [${component}]${corrStr} ${message}${metaStr}`; } +function buildEntry( + level: string, + component: string, + message: string, + meta?: Record +) { + const entry: Record = { + timestamp: new Date().toISOString(), + level, + component, + message, + ...meta, + }; + const correlationId = getCorrelationId() as string | undefined; + if (correlationId) { + entry.correlationId = correlationId; + } + return entry; +} + export function createLogger(component: string) { return { debug(message: string, meta?: Record) { if (currentLevel <= LOG_LEVELS.debug) { + const entry = buildEntry("debug", component, message, meta); console.debug(formatEntry("debug", component, message, meta)); + writeToFile(entry); } }, info(message: string, meta?: Record) { if (currentLevel <= LOG_LEVELS.info) { + const entry = buildEntry("info", component, message, meta); console.info(formatEntry("info", component, message, meta)); + writeToFile(entry); } }, warn(message: string, meta?: Record) { if (currentLevel <= LOG_LEVELS.warn) { + const entry = buildEntry("warn", component, message, meta); console.warn(formatEntry("warn", component, message, meta)); + writeToFile(entry); } }, error(message: string, meta?: Record) { if (currentLevel <= LOG_LEVELS.error) { + const entry = buildEntry("error", component, message, meta); console.error(formatEntry("error", component, message, meta)); + writeToFile(entry); } }, fatal(message: string, meta?: Record) { + const entry = buildEntry("fatal", component, message, meta); console.error(formatEntry("fatal", component, message, meta)); + writeToFile(entry); }, child(defaultMeta: Record) { return createLogger(component);