feat(logs): add Logs Dashboard with real-time Console Viewer
- Consolidated 4-tab Logs page: Request Logs, Proxy Logs, Audit Logs, Console - Terminal-style Console Log Viewer with level filter, search, auto-scroll - Console interceptor captures all console.log/warn/error to JSON log file - Integrated in Next.js instrumentation.ts for both dev and prod - Log rotation by size + retention-based cleanup - Fixed pino logger file transport (pino/file targets only) - Moved initAuditLog() to instrumentation.ts for proper initialization - Bumped version to 1.0.3 - Updated CHANGELOG, all 8 README translations, and .env.example
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -94,3 +94,4 @@ security-analysis/
|
||||
|
||||
# Deploy workflow (contains sensitive VPS credentials)
|
||||
.agent/workflows/deploy.md
|
||||
clipr/
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
</details>
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+4
-4
@@ -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`
|
||||
|
||||
</details>
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+4
-4
@@ -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`
|
||||
|
||||
</details>
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+4
-4
@@ -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`
|
||||
|
||||
</details>
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
</details>
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+4
-4
@@ -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`
|
||||
|
||||
</details>
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+4
-4
@@ -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`
|
||||
|
||||
</details>
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+4
-4
@@ -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` 后缀
|
||||
|
||||
</details>
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Submodule
+1
Submodule clipr/9router added at bc91be7305
Submodule
+1
Submodule clipr/CLIProxyAPI added at 068630dbd0
Generated
+2
-2
@@ -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"
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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<AuditEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-[var(--color-text-main)]">Audit Log</h2>
|
||||
<p className="text-sm text-[var(--color-text-muted)] mt-1">
|
||||
Administrative actions and security events
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchEntries}
|
||||
disabled={loading}
|
||||
aria-label="Refresh audit log"
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Loading..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div
|
||||
className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]"
|
||||
role="search"
|
||||
aria-label="Filter audit log entries"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by action..."
|
||||
value={actionFilter}
|
||||
onChange={(e) => 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)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by actor..."
|
||||
value={actorFilter}
|
||||
onChange={(e) => 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)]"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-hover)] transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-xl border border-[var(--color-border)]">
|
||||
<table className="w-full text-sm" role="table" aria-label="Audit log entries">
|
||||
<thead>
|
||||
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
Timestamp
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
Action
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
Actor
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
Target
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
Details
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.length === 0 && !loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-[var(--color-text-muted)]">
|
||||
No audit log entries found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-[var(--color-text-muted)] font-mono text-xs">
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${actionBadgeColor(entry.action)}`}
|
||||
>
|
||||
{entry.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-main)]">{entry.actor}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[200px] truncate">
|
||||
{entry.target || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[300px] truncate font-mono text-xs">
|
||||
{entry.details ? JSON.stringify(entry.details) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-muted)] font-mono text-xs whitespace-nowrap">
|
||||
{entry.ip_address || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Showing {entries.length} entries (offset {offset})
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
disabled={offset === 0}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
disabled={!hasMore}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: "request-logs", label: "Request Logs" },
|
||||
{ value: "proxy-logs", label: "Proxy Logs" },
|
||||
{ value: "audit-logs", label: "Audit Logs" },
|
||||
{ value: "console", label: "Console" },
|
||||
]}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === "request-logs" && <RequestLoggerV2 />}
|
||||
{activeTab === "proxy-logs" && <ProxyLogger />}
|
||||
{activeTab === "audit-logs" && <AuditLogTab />}
|
||||
{activeTab === "console" && <ConsoleLogViewer />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, number> = {
|
||||
trace: 5,
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
fatal: 50,
|
||||
};
|
||||
|
||||
// Map pino numeric levels to string levels
|
||||
const NUMERIC_LEVEL_MAP: Record<number, string> = {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, unknown>)[method] = (...args: unknown[]) => {
|
||||
// Call original console method
|
||||
original(...args);
|
||||
// Also write to file
|
||||
writeEntry(level, args);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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}
|
||||
</Link>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<LogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [levelFilter, setLevelFilter] = useState("all");
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [copiedIdx, setCopiedIdx] = useState<number | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]">
|
||||
{/* Level filter */}
|
||||
<select
|
||||
value={levelFilter}
|
||||
onChange={(e) => setLevelFilter(e.target.value)}
|
||||
aria-label="Filter by log level"
|
||||
className="px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] focus:outline-2 focus:outline-[var(--color-accent)]"
|
||||
>
|
||||
<option value="all">All Levels</option>
|
||||
<option value="debug">Debug+</option>
|
||||
<option value="info">Info+</option>
|
||||
<option value="warn">Warn+</option>
|
||||
<option value="error">Error+</option>
|
||||
</select>
|
||||
|
||||
{/* Search */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
value={searchText}
|
||||
onChange={(e) => 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 */}
|
||||
<button
|
||||
onClick={() => setAutoScroll(!autoScroll)}
|
||||
title={autoScroll ? "Disable auto-scroll" : "Enable auto-scroll"}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
autoScroll
|
||||
? "bg-cyan-500/15 text-cyan-400 border-cyan-500/30"
|
||||
: "bg-[var(--color-bg)] text-[var(--color-text-muted)] border-[var(--color-border)]"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px] align-middle mr-1">
|
||||
{autoScroll ? "vertical_align_bottom" : "lock"}
|
||||
</span>
|
||||
Auto-scroll
|
||||
</button>
|
||||
|
||||
{/* Refresh */}
|
||||
<button
|
||||
onClick={fetchLogs}
|
||||
disabled={loading}
|
||||
className="px-3 py-2 rounded-lg text-sm font-medium bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px] align-middle">refresh</span>
|
||||
</button>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2 ml-auto text-xs text-[var(--color-text-muted)]">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span>{filteredLogs.length} entries</span>
|
||||
<span className="text-[var(--color-text-muted)]/50">•</span>
|
||||
<span>Last 1h</span>
|
||||
{lastUpdated && (
|
||||
<>
|
||||
<span className="text-[var(--color-text-muted)]/50">•</span>
|
||||
<span>Updated {lastUpdated.toLocaleTimeString()}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm"
|
||||
role="alert"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px] align-middle mr-2">error</span>
|
||||
{error}
|
||||
<span className="text-xs ml-2 opacity-70">
|
||||
— Make sure the application is writing logs to file (LOG_TO_FILE=true)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Console output */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="rounded-xl border border-[var(--color-border)] bg-[#0d1117] overflow-auto font-mono text-xs leading-relaxed"
|
||||
style={{ maxHeight: "calc(100vh - 340px)", minHeight: "400px" }}
|
||||
role="log"
|
||||
aria-label="Application console logs"
|
||||
aria-live="polite"
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div className="sticky top-0 z-10 px-4 py-2 bg-[#161b22] border-b border-[#30363d] flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-[#FF5F56]" />
|
||||
<div className="w-3 h-3 rounded-full bg-[#FFBD2E]" />
|
||||
<div className="w-3 h-3 rounded-full bg-[#27C93F]" />
|
||||
<span className="ml-3 text-[#8b949e] text-[11px]">OmniRoute — Application Console</span>
|
||||
</div>
|
||||
|
||||
{/* Log entries */}
|
||||
<div className="p-3 space-y-px">
|
||||
{filteredLogs.length === 0 && !loading ? (
|
||||
<div className="text-[#8b949e] text-center py-12">
|
||||
<span className="material-symbols-outlined text-[40px] block mb-2 opacity-30">
|
||||
terminal
|
||||
</span>
|
||||
<p>No log entries found</p>
|
||||
<p className="text-[10px] mt-1 opacity-60">
|
||||
Ensure LOG_TO_FILE=true is set in your .env file
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
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 (
|
||||
<div
|
||||
key={idx}
|
||||
className={`group flex items-start gap-2 px-2 py-1 rounded hover:bg-white/5 transition-colors ${
|
||||
level === "error" || level === "fatal" ? "bg-red-500/5" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Timestamp */}
|
||||
<span className="text-[#484f58] whitespace-nowrap shrink-0 select-none">
|
||||
{formatTime(entry.timestamp)}
|
||||
</span>
|
||||
|
||||
{/* Level badge */}
|
||||
<span
|
||||
className={`inline-block px-1.5 py-0 rounded text-[10px] font-semibold uppercase border shrink-0 ${colorClass} ${bgClass}`}
|
||||
>
|
||||
{level.padEnd(5)}
|
||||
</span>
|
||||
|
||||
{/* Component */}
|
||||
{comp && <span className="text-purple-400/80 shrink-0">[{comp}]</span>}
|
||||
|
||||
{/* Message */}
|
||||
<span className="text-[#c9d1d9] flex-1 break-all">
|
||||
{msg}
|
||||
{/* Extra meta */}
|
||||
{entry.correlationId && (
|
||||
<span className="text-[#484f58] ml-2">
|
||||
cid:{entry.correlationId.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Copy button */}
|
||||
<button
|
||||
onClick={() => handleCopy(entry, idx)}
|
||||
title="Copy log entry"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity shrink-0 text-[#8b949e] hover:text-white"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{copiedIdx === idx ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{loading && filteredLogs.length === 0 && (
|
||||
<div className="text-[#8b949e] text-center py-12">
|
||||
<span className="material-symbols-outlined text-[24px] animate-spin block mb-2">
|
||||
progress_activity
|
||||
</span>
|
||||
Loading logs...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, number> = {
|
||||
debug: 10,
|
||||
@@ -21,7 +26,40 @@ const LOG_LEVELS: Record<string, number> = {
|
||||
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<string, unknown>) {
|
||||
// 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<string, unknown>) {
|
||||
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<string, unknown>
|
||||
) {
|
||||
const entry: Record<string, unknown> = {
|
||||
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<string, unknown>
|
||||
) {
|
||||
const entry: Record<string, unknown> = {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
const entry = buildEntry("fatal", component, message, meta);
|
||||
console.error(formatEntry("fatal", component, message, meta));
|
||||
writeToFile(entry);
|
||||
},
|
||||
child(defaultMeta: Record<string, unknown>) {
|
||||
return createLogger(component);
|
||||
|
||||
Reference in New Issue
Block a user