From 6a38c236b2daf2be105140be6a249d85a5227ceb Mon Sep 17 00:00:00 2001 From: David Date: Thu, 2 Apr 2026 12:46:10 +0200 Subject: [PATCH] feat: add centralized logging stack with log exporter - Add Loki + Promtail + Grafana logging infrastructure - Add log-exporter service (REST API to query logs) - Add docker-compose.logging.yml (standalone logging stack) - Add docker-compose.full.yml (merged dev + logging in one file) - Update docker-compose.dev.yml with network labels for Promtail - Add admin logs dashboard page - Fix invitation DTO and users settings page Co-Authored-By: Claude Sonnet 4.6 --- apps/backend/src/app.module.ts | 31 +- .../src/application/dto/invitation.dto.ts | 1 - .../app/dashboard/admin/logs/page.tsx | 548 +++++++++++++++ .../app/dashboard/settings/users/page.tsx | 6 +- .../components/admin/AdminPanelDropdown.tsx | 8 +- apps/log-exporter/Dockerfile | 14 + apps/log-exporter/package.json | 15 + apps/log-exporter/src/index.js | 319 +++++++++ docker-compose.dev.yml | 28 +- docker-compose.full.yml | 254 +++++++ docker-compose.logging.yml | 115 ++++ .../provisioning/dashboards/provider.yml | 12 + .../dashboards/xpeditis-logs.json | 636 ++++++++++++++++++ .../grafana/provisioning/datasources/loki.yml | 19 + infra/logging/loki/loki-config.yml | 62 ++ infra/logging/promtail/promtail-config.yml | 70 ++ 16 files changed, 2118 insertions(+), 20 deletions(-) create mode 100644 apps/frontend/app/dashboard/admin/logs/page.tsx create mode 100644 apps/log-exporter/Dockerfile create mode 100644 apps/log-exporter/package.json create mode 100644 apps/log-exporter/src/index.js create mode 100644 docker-compose.full.yml create mode 100644 docker-compose.logging.yml create mode 100644 infra/logging/grafana/provisioning/dashboards/provider.yml create mode 100644 infra/logging/grafana/provisioning/dashboards/xpeditis-logs.json create mode 100644 infra/logging/grafana/provisioning/datasources/loki.yml create mode 100644 infra/logging/loki/loki-config.yml create mode 100644 infra/logging/promtail/promtail-config.yml diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 898c0c3..7e7ada3 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -72,10 +72,15 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; // Logging LoggerModule.forRootAsync({ - useFactory: (configService: ConfigService) => ({ - pinoHttp: { - transport: - configService.get('NODE_ENV') === 'development' + useFactory: (configService: ConfigService) => { + const isDev = configService.get('NODE_ENV') === 'development'; + // LOG_FORMAT=json forces structured JSON output (e.g. inside Docker + Promtail) + const forceJson = configService.get('LOG_FORMAT') === 'json'; + const usePretty = isDev && !forceJson; + + return { + pinoHttp: { + transport: usePretty ? { target: 'pino-pretty', options: { @@ -85,9 +90,21 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; }, } : undefined, - level: configService.get('NODE_ENV') === 'production' ? 'info' : 'debug', - }, - }), + level: isDev ? 'debug' : 'info', + // Redact sensitive fields from logs + redact: { + paths: [ + 'req.headers.authorization', + 'req.headers["x-api-key"]', + 'req.body.password', + 'req.body.currentPassword', + 'req.body.newPassword', + ], + censor: '[REDACTED]', + }, + }, + }; + }, inject: [ConfigService], }), diff --git a/apps/backend/src/application/dto/invitation.dto.ts b/apps/backend/src/application/dto/invitation.dto.ts index e8840f8..07aa0c4 100644 --- a/apps/backend/src/application/dto/invitation.dto.ts +++ b/apps/backend/src/application/dto/invitation.dto.ts @@ -2,7 +2,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsEmail, IsString, MinLength, IsEnum, IsOptional } from 'class-validator'; export enum InvitationRole { - ADMIN = 'ADMIN', MANAGER = 'MANAGER', USER = 'USER', VIEWER = 'VIEWER', diff --git a/apps/frontend/app/dashboard/admin/logs/page.tsx b/apps/frontend/app/dashboard/admin/logs/page.tsx new file mode 100644 index 0000000..edf04ed --- /dev/null +++ b/apps/frontend/app/dashboard/admin/logs/page.tsx @@ -0,0 +1,548 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { + Download, + RefreshCw, + Filter, + Activity, + AlertTriangle, + Info, + Bug, + Server, +} from 'lucide-react'; + +const LOG_EXPORTER_URL = + process.env.NEXT_PUBLIC_LOG_EXPORTER_URL || 'http://localhost:3200'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface LogEntry { + timestamp: string; + service: string; + level: string; + context: string; + message: string; + reqId: string; + req_method: string; + req_url: string; + res_status: string; + response_time_ms: string; + error: string; +} + +interface LogsResponse { + total: number; + query: string; + range: { from: string; to: string }; + logs: LogEntry[]; +} + +interface Filters { + service: string; + level: string; + search: string; + startDate: string; + endDate: string; + limit: string; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const LEVEL_STYLES: Record = { + error: 'bg-red-100 text-red-700 border border-red-200', + fatal: 'bg-red-200 text-red-900 border border-red-300', + warn: 'bg-yellow-100 text-yellow-700 border border-yellow-200', + info: 'bg-blue-100 text-blue-700 border border-blue-200', + debug: 'bg-gray-100 text-gray-600 border border-gray-200', + trace: 'bg-purple-100 text-purple-700 border border-purple-200', +}; + +const LEVEL_ROW_BG: Record = { + error: 'bg-red-50', + fatal: 'bg-red-100', + warn: 'bg-yellow-50', + info: '', + debug: '', + trace: '', +}; + +function LevelBadge({ level }: { level: string }) { + const style = LEVEL_STYLES[level] || 'bg-gray-100 text-gray-600'; + return ( + + {level} + + ); +} + +function StatCard({ + label, + value, + icon: Icon, + color, +}: { + label: string; + value: number | string; + icon: any; + color: string; +}) { + return ( +
+
+ +
+
+

{value}

+

{label}

+
+
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function AdminLogsPage() { + const [logs, setLogs] = useState([]); + const [services, setServices] = useState([]); + const [loading, setLoading] = useState(false); + const [exportLoading, setExportLoading] = useState(false); + const [error, setError] = useState(null); + const [total, setTotal] = useState(0); + const [expandedRow, setExpandedRow] = useState(null); + + const now = new Date(); + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); + + const [filters, setFilters] = useState({ + service: 'all', + level: 'all', + search: '', + startDate: oneHourAgo.toISOString().slice(0, 16), + endDate: now.toISOString().slice(0, 16), + limit: '500', + }); + + // Load available services + useEffect(() => { + fetch(`${LOG_EXPORTER_URL}/api/logs/services`) + .then(r => r.json()) + .then(d => setServices(d.services || [])) + .catch(() => {}); + }, []); + + const buildQueryString = useCallback( + (fmt?: string) => { + const params = new URLSearchParams(); + if (filters.service !== 'all') params.set('service', filters.service); + if (filters.level !== 'all') params.set('level', filters.level); + if (filters.search) params.set('search', filters.search); + if (filters.startDate) params.set('start', new Date(filters.startDate).toISOString()); + if (filters.endDate) params.set('end', new Date(filters.endDate).toISOString()); + params.set('limit', filters.limit); + if (fmt) params.set('format', fmt); + return params.toString(); + }, + [filters], + ); + + const fetchLogs = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch( + `${LOG_EXPORTER_URL}/api/logs/export?${buildQueryString('json')}`, + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${res.status}`); + } + const data: LogsResponse = await res.json(); + setLogs(data.logs || []); + setTotal(data.total || 0); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }, [buildQueryString]); + + useEffect(() => { + fetchLogs(); + }, []); + + const handleExport = async (format: 'json' | 'csv') => { + setExportLoading(true); + try { + const res = await fetch( + `${LOG_EXPORTER_URL}/api/logs/export?${buildQueryString(format)}`, + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `xpeditis-logs-${new Date().toISOString().slice(0, 10)}.${format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (err: any) { + setError(err.message); + } finally { + setExportLoading(false); + } + }; + + // Stats + const countByLevel = (level: string) => + logs.filter(l => l.level === level).length; + + const setFilter = (key: keyof Filters, value: string) => + setFilters(prev => ({ ...prev, [key]: value })); + + return ( +
+ {/* Header */} +
+
+

Logs système

+

+ Visualisation et export des logs applicatifs en temps réel +

+
+
+ +
+ +
+ + +
+
+
+
+ + {/* Stats */} +
+ + + + +
+ + {/* Filters */} +
+
+ +

Filtres

+
+
+ {/* Service */} +
+ + +
+ + {/* Level */} +
+ + +
+ + {/* Search */} +
+ + setFilter('search', e.target.value)} + onKeyDown={e => e.key === 'Enter' && fetchLogs()} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none" + /> +
+ + {/* Start */} +
+ + setFilter('startDate', e.target.value)} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none" + /> +
+ + {/* End */} +
+ + setFilter('endDate', e.target.value)} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none" + /> +
+ + {/* Limit + Apply */} +
+ +
+ + +
+
+
+
+ + {/* Error */} + {error && ( +
+ + + Impossible de contacter le log-exporter : {error} +
+ + Vérifiez que le container log-exporter est démarré sur{' '} + {LOG_EXPORTER_URL} + +
+
+ )} + + {/* Table */} +
+
+
+ + + {loading ? 'Chargement...' : `${total} entrée${total !== 1 ? 's' : ''}`} + +
+ {!loading && logs.length > 0 && ( + + Cliquer sur une ligne pour les détails + + )} +
+ + {loading ? ( +
+
+
+ ) : logs.length === 0 && !error ? ( +
+ +

Aucun log trouvé pour ces filtres

+
+ ) : ( +
+ + + + + + + + + + + + + {logs.map((log, i) => ( + <> + setExpandedRow(expandedRow === i ? null : i)} + className={`cursor-pointer hover:bg-gray-50 transition-colors ${LEVEL_ROW_BG[log.level] || ''}`} + > + + + + + + + + + {/* Expanded detail row */} + {expandedRow === i && ( + + + + )} + + ))} + +
+ Timestamp + + Service + + Niveau + + Contexte + + Message + + Req / Status +
+ {new Date(log.timestamp).toLocaleString('fr-FR', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} + + + {log.service} + + + + + {log.context || '—'} + + + {log.error ? ( + {log.error} + ) : ( + log.message + )} + + + {log.req_method && ( + + {log.req_method}{' '} + {log.req_url}{' '} + {log.res_status && ( + + {log.res_status} + + )} + + )} +
+
+
+ Timestamp +

{log.timestamp}

+
+ {log.reqId && ( +
+ Request ID +

{log.reqId}

+
+ )} + {log.response_time_ms && ( +
+ Durée +

+ {log.response_time_ms} ms +

+
+ )} +
+ Message complet +
+                                {log.error
+                                  ? `[ERROR] ${log.error}\n\n${log.message}`
+                                  : log.message}
+                              
+
+
+
+
+ )} +
+
+ ); +} diff --git a/apps/frontend/app/dashboard/settings/users/page.tsx b/apps/frontend/app/dashboard/settings/users/page.tsx index b084e1f..2b10987 100644 --- a/apps/frontend/app/dashboard/settings/users/page.tsx +++ b/apps/frontend/app/dashboard/settings/users/page.tsx @@ -80,7 +80,7 @@ export default function UsersManagementPage() { email: '', firstName: '', lastName: '', - role: 'USER' as 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER', + role: 'USER' as 'MANAGER' | 'USER' | 'VIEWER', }); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); @@ -635,12 +635,8 @@ export default function UsersManagementPage() { > - {currentUser?.role === 'ADMIN' && } - {currentUser?.role !== 'ADMIN' && ( -

Seuls les administrateurs peuvent attribuer le rôle ADMIN

- )}