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 <noreply@anthropic.com>
This commit is contained in:
parent
e1f813bd92
commit
6a38c236b2
@ -72,10 +72,15 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
||||
|
||||
// Logging
|
||||
LoggerModule.forRootAsync({
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
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:
|
||||
configService.get('NODE_ENV') === 'development'
|
||||
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],
|
||||
}),
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
548
apps/frontend/app/dashboard/admin/logs/page.tsx
Normal file
548
apps/frontend/app/dashboard/admin/logs/page.tsx
Normal file
@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-xs font-mono font-semibold uppercase ${style}`}>
|
||||
{level}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
icon: any;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border p-4 flex items-center gap-4">
|
||||
<div className={`p-2 rounded-lg ${color}`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900">{value}</p>
|
||||
<p className="text-sm text-gray-500">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AdminLogsPage() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [services, setServices] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [expandedRow, setExpandedRow] = useState<number | null>(null);
|
||||
|
||||
const now = new Date();
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Logs système</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Visualisation et export des logs applicatifs en temps réel
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={fetchLogs}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Actualiser
|
||||
</button>
|
||||
<div className="relative group">
|
||||
<button
|
||||
disabled={exportLoading || loading}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-[#10183A] rounded-lg hover:bg-[#1a2550] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{exportLoading ? 'Export...' : 'Exporter'}
|
||||
</button>
|
||||
<div className="absolute right-0 mt-1 w-36 bg-white rounded-lg shadow-lg border border-gray-200 z-10 hidden group-hover:block">
|
||||
<button
|
||||
onClick={() => handleExport('csv')}
|
||||
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Télécharger CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport('json')}
|
||||
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Télécharger JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Total logs"
|
||||
value={total}
|
||||
icon={Activity}
|
||||
color="bg-blue-100 text-blue-600"
|
||||
/>
|
||||
<StatCard
|
||||
label="Erreurs"
|
||||
value={countByLevel('error') + countByLevel('fatal')}
|
||||
icon={AlertTriangle}
|
||||
color="bg-red-100 text-red-600"
|
||||
/>
|
||||
<StatCard
|
||||
label="Warnings"
|
||||
value={countByLevel('warn')}
|
||||
icon={AlertTriangle}
|
||||
color="bg-yellow-100 text-yellow-600"
|
||||
/>
|
||||
<StatCard
|
||||
label="Info"
|
||||
value={countByLevel('info')}
|
||||
icon={Info}
|
||||
color="bg-green-100 text-green-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Filter className="h-4 w-4 text-gray-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700">Filtres</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">
|
||||
{/* Service */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Service</label>
|
||||
<select
|
||||
value={filters.service}
|
||||
onChange={e => setFilter('service', 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"
|
||||
>
|
||||
<option value="all">Tous</option>
|
||||
{services.map(s => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Level */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Niveau</label>
|
||||
<select
|
||||
value={filters.level}
|
||||
onChange={e => setFilter('level', 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"
|
||||
>
|
||||
<option value="all">Tous</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="fatal">Fatal</option>
|
||||
<option value="warn">Warn</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="debug">Debug</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Recherche</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Texte libre..."
|
||||
value={filters.search}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Start */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Début</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={filters.startDate}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* End */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Fin</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={filters.endDate}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Limit + Apply */}
|
||||
<div className="flex flex-col justify-end gap-2">
|
||||
<label className="block text-xs font-medium text-gray-500">Limite</label>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={filters.limit}
|
||||
onChange={e => setFilter('limit', e.target.value)}
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:outline-none"
|
||||
>
|
||||
<option value="100">100</option>
|
||||
<option value="500">500</option>
|
||||
<option value="1000">1000</option>
|
||||
<option value="5000">5000</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={fetchLogs}
|
||||
disabled={loading}
|
||||
className="px-3 py-2 text-sm font-medium text-white bg-[#34CCCD] rounded-lg hover:bg-[#2bb8b9] transition-colors disabled:opacity-50 whitespace-nowrap"
|
||||
>
|
||||
Filtrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Impossible de contacter le log-exporter : <strong>{error}</strong>
|
||||
<br />
|
||||
<span className="text-xs text-red-500">
|
||||
Vérifiez que le container log-exporter est démarré sur{' '}
|
||||
<code className="font-mono">{LOG_EXPORTER_URL}</code>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-lg border overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-gray-50 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{loading ? 'Chargement...' : `${total} entrée${total !== 1 ? 's' : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
{!loading && logs.length > 0 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
Cliquer sur une ligne pour les détails
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#34CCCD]" />
|
||||
</div>
|
||||
) : logs.length === 0 && !error ? (
|
||||
<div className="flex flex-col items-center justify-center h-40 text-gray-400 gap-2">
|
||||
<Bug className="h-8 w-8" />
|
||||
<p className="text-sm">Aucun log trouvé pour ces filtres</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">
|
||||
Timestamp
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Service
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Niveau
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Contexte
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Message
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">
|
||||
Req / Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{logs.map((log, i) => (
|
||||
<>
|
||||
<tr
|
||||
key={i}
|
||||
onClick={() => setExpandedRow(expandedRow === i ? null : i)}
|
||||
className={`cursor-pointer hover:bg-gray-50 transition-colors ${LEVEL_ROW_BG[log.level] || ''}`}
|
||||
>
|
||||
<td className="px-4 py-2 font-mono text-xs text-gray-500 whitespace-nowrap">
|
||||
{new Date(log.timestamp).toLocaleString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
<span className="px-2 py-0.5 bg-[#10183A] text-white text-xs rounded font-mono">
|
||||
{log.service}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
<LevelBadge level={log.level} />
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-gray-500 whitespace-nowrap">
|
||||
{log.context || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 max-w-xs">
|
||||
<span className="line-clamp-1 text-gray-800">
|
||||
{log.error ? (
|
||||
<span className="text-red-600">{log.error}</span>
|
||||
) : (
|
||||
log.message
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-gray-500 whitespace-nowrap">
|
||||
{log.req_method && (
|
||||
<span>
|
||||
<span className="font-semibold">{log.req_method}</span>{' '}
|
||||
{log.req_url}{' '}
|
||||
{log.res_status && (
|
||||
<span
|
||||
className={
|
||||
String(log.res_status).startsWith('5')
|
||||
? 'text-red-500 font-bold'
|
||||
: String(log.res_status).startsWith('4')
|
||||
? 'text-yellow-600 font-bold'
|
||||
: 'text-green-600'
|
||||
}
|
||||
>
|
||||
{log.res_status}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Expanded detail row */}
|
||||
{expandedRow === i && (
|
||||
<tr key={`detail-${i}`} className="bg-gray-50">
|
||||
<td colSpan={6} className="px-4 py-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-600">Timestamp</span>
|
||||
<p className="font-mono text-gray-800 mt-0.5">{log.timestamp}</p>
|
||||
</div>
|
||||
{log.reqId && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-600">Request ID</span>
|
||||
<p className="font-mono text-gray-800 mt-0.5 truncate">{log.reqId}</p>
|
||||
</div>
|
||||
)}
|
||||
{log.response_time_ms && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-600">Durée</span>
|
||||
<p className="font-mono text-gray-800 mt-0.5">
|
||||
{log.response_time_ms} ms
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-span-2 md:col-span-4">
|
||||
<span className="font-semibold text-gray-600">Message complet</span>
|
||||
<pre className="mt-0.5 p-2 bg-white rounded border font-mono text-gray-800 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{log.error
|
||||
? `[ERROR] ${log.error}\n\n${log.message}`
|
||||
: log.message}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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() {
|
||||
>
|
||||
<option value="USER">Utilisateur</option>
|
||||
<option value="MANAGER">Manager</option>
|
||||
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Administrateur</option>}
|
||||
<option value="VIEWER">Lecteur</option>
|
||||
</select>
|
||||
{currentUser?.role !== 'ADMIN' && (
|
||||
<p className="mt-1 text-xs text-gray-500">Seuls les administrateurs peuvent attribuer le rôle ADMIN</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
||||
<button
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Users, Building2, Package, FileText, BarChart3, Settings, type LucideIcon } from 'lucide-react';
|
||||
import { Users, Building2, Package, FileText, BarChart3, Settings, ScrollText, type LucideIcon } from 'lucide-react';
|
||||
|
||||
interface AdminMenuItem {
|
||||
name: string;
|
||||
@ -43,6 +43,12 @@ const adminMenuItems: AdminMenuItem[] = [
|
||||
icon: BarChart3,
|
||||
description: 'Importer et gérer les tarifs CSV',
|
||||
},
|
||||
{
|
||||
name: 'Logs système',
|
||||
href: '/dashboard/admin/logs',
|
||||
icon: ScrollText,
|
||||
description: 'Visualiser et télécharger les logs applicatifs',
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminPanelDropdown() {
|
||||
|
||||
14
apps/log-exporter/Dockerfile
Normal file
14
apps/log-exporter/Dockerfile
Normal file
@ -0,0 +1,14 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY src/ ./src/
|
||||
|
||||
EXPOSE 3200
|
||||
|
||||
USER node
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
15
apps/log-exporter/package.json
Normal file
15
apps/log-exporter/package.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "xpeditis-log-exporter",
|
||||
"version": "1.0.0",
|
||||
"description": "Log export API for Xpeditis - queries Loki and exports logs as CSV/JSON",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"json2csv": "^6.0.0-alpha.2"
|
||||
}
|
||||
}
|
||||
319
apps/log-exporter/src/index.js
Normal file
319
apps/log-exporter/src/index.js
Normal file
@ -0,0 +1,319 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const { Transform } = require('stream');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3200;
|
||||
const LOKI_URL = process.env.LOKI_URL || 'http://loki:3100';
|
||||
const API_KEY = process.env.LOG_EXPORTER_API_KEY;
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Simple API key middleware (optional, enabled when LOG_EXPORTER_API_KEY is set).
|
||||
*/
|
||||
function authMiddleware(req, res, next) {
|
||||
if (!API_KEY) return next();
|
||||
const key = req.headers['x-api-key'] || req.query.apiKey;
|
||||
if (key !== API_KEY) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Loki LogQL query from request params.
|
||||
* Supports: service, level, search (free text filter)
|
||||
*/
|
||||
function buildLogQLQuery({ service, level, search }) {
|
||||
const labelFilters = [];
|
||||
|
||||
if (service && service !== 'all') {
|
||||
const services = service.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (services.length === 1) {
|
||||
labelFilters.push(`service="${services[0]}"`);
|
||||
} else {
|
||||
labelFilters.push(`service=~"${services.join('|')}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (level && level !== 'all') {
|
||||
const levels = level.split(',').map((l) => l.trim()).filter(Boolean);
|
||||
if (levels.length === 1) {
|
||||
labelFilters.push(`level="${levels[0]}"`);
|
||||
} else {
|
||||
labelFilters.push(`level=~"${levels.join('|')}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const streamSelector = labelFilters.length > 0
|
||||
? `{${labelFilters.join(', ')}}`
|
||||
: `{service=~".+"}`;
|
||||
|
||||
const lineFilters = search ? ` |= \`${search}\`` : '';
|
||||
|
||||
return `${streamSelector}${lineFilters}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Loki's query_range endpoint and return flattened log entries.
|
||||
*/
|
||||
async function queryLoki({ query, start, end, limit = 5000 }) {
|
||||
const params = new URLSearchParams({
|
||||
query,
|
||||
start: String(start),
|
||||
end: String(end),
|
||||
limit: String(Math.min(limit, 5000)),
|
||||
direction: 'BACKWARD',
|
||||
});
|
||||
|
||||
const url = `${LOKI_URL}/loki/api/v1/query_range?${params}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Loki query failed (${response.status}): ${body}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status !== 'success') {
|
||||
throw new Error(`Loki returned status: ${data.status}`);
|
||||
}
|
||||
|
||||
// Flatten streams → individual log entries
|
||||
const entries = [];
|
||||
for (const stream of data.data.result || []) {
|
||||
const labels = stream.stream || {};
|
||||
for (const [tsNano, line] of stream.values || []) {
|
||||
let parsed = {};
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
parsed = { msg: line };
|
||||
}
|
||||
|
||||
entries.push({
|
||||
timestamp: new Date(Math.floor(Number(tsNano) / 1e6)).toISOString(),
|
||||
service: labels.service || labels.container || 'unknown',
|
||||
level: labels.level || parsed.level || 'info',
|
||||
context: labels.context || parsed.context || '',
|
||||
message: parsed.msg || parsed.message || line,
|
||||
reqId: parsed.reqId || '',
|
||||
req_method: parsed.req?.method || '',
|
||||
req_url: parsed.req?.url || '',
|
||||
res_status: parsed.res?.statusCode || '',
|
||||
response_time_ms: parsed.responseTime || '',
|
||||
error: parsed.err?.message || '',
|
||||
raw: line,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp ascending
|
||||
entries.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert array of objects to CSV string.
|
||||
*/
|
||||
function toCSV(entries) {
|
||||
if (entries.length === 0) return '';
|
||||
|
||||
const headers = [
|
||||
'timestamp', 'service', 'level', 'context',
|
||||
'message', 'reqId', 'req_method', 'req_url',
|
||||
'res_status', 'response_time_ms', 'error',
|
||||
];
|
||||
|
||||
const escape = (val) => {
|
||||
if (val === null || val === undefined) return '';
|
||||
const str = String(val);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
const rows = [headers.join(',')];
|
||||
for (const entry of entries) {
|
||||
rows.push(headers.map((h) => escape(entry[h])).join(','));
|
||||
}
|
||||
return rows.join('\n');
|
||||
}
|
||||
|
||||
// ─── Routes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Rate limiting (basic — 60 requests/min per IP)
|
||||
const requestCounts = new Map();
|
||||
setInterval(() => requestCounts.clear(), 60000);
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const ip = req.ip;
|
||||
const count = (requestCounts.get(ip) || 0) + 1;
|
||||
requestCounts.set(ip, count);
|
||||
if (count > 60) {
|
||||
return res.status(429).json({ error: 'Too Many Requests' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// CORS for Grafana / frontend
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-api-key');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /health
|
||||
*/
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', loki: LOKI_URL });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/logs/services
|
||||
* Returns the list of services currently emitting logs.
|
||||
*/
|
||||
app.get('/api/logs/services', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const response = await fetch(`${LOKI_URL}/loki/api/v1/label/service/values`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Loki error: ${response.status}`);
|
||||
const data = await response.json();
|
||||
res.json({ services: data.data || [] });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/logs/labels
|
||||
* Returns all available label names.
|
||||
*/
|
||||
app.get('/api/logs/labels', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const response = await fetch(`${LOKI_URL}/loki/api/v1/labels`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Loki error: ${response.status}`);
|
||||
const data = await response.json();
|
||||
res.json({ labels: data.data || [] });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/logs/export
|
||||
*
|
||||
* Query params:
|
||||
* - start : ISO date or Unix timestamp in ns (default: 1h ago)
|
||||
* - end : ISO date or Unix timestamp in ns (default: now)
|
||||
* - service : comma-separated service names (default: all)
|
||||
* - level : comma-separated levels: error,warn,info,debug (default: all)
|
||||
* - search : free-text search string
|
||||
* - limit : max number of log lines (default: 5000, max: 5000)
|
||||
* - format : "json" | "csv" (default: json)
|
||||
*
|
||||
* Examples:
|
||||
* GET /api/logs/export?service=backend&level=error&format=csv
|
||||
* GET /api/logs/export?start=2024-01-01T00:00:00Z&end=2024-01-02T00:00:00Z&format=json
|
||||
*/
|
||||
app.get('/api/logs/export', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const ONE_HOUR_NS = 3600 * 1e9;
|
||||
const nowNs = BigInt(now) * 1000000n;
|
||||
const oneHourAgoNs = nowNs - BigInt(ONE_HOUR_NS);
|
||||
|
||||
// Parse time range
|
||||
const parseTime = (val, defaultNs) => {
|
||||
if (!val) return defaultNs;
|
||||
// Already in nanoseconds (large number)
|
||||
if (/^\d{18,}$/.test(val)) return BigInt(val);
|
||||
// Unix timestamp in seconds or ms
|
||||
const n = Number(val);
|
||||
if (!isNaN(n)) {
|
||||
// seconds → ns
|
||||
if (n < 1e12) return BigInt(Math.floor(n * 1e9));
|
||||
// ms → ns
|
||||
if (n < 1e15) return BigInt(n) * 1000000n;
|
||||
return BigInt(n);
|
||||
}
|
||||
// ISO date string
|
||||
const ms = Date.parse(val);
|
||||
if (isNaN(ms)) throw new Error(`Invalid time value: ${val}`);
|
||||
return BigInt(ms) * 1000000n;
|
||||
};
|
||||
|
||||
const startNs = parseTime(req.query.start, oneHourAgoNs);
|
||||
const endNs = parseTime(req.query.end, nowNs);
|
||||
|
||||
if (endNs <= startNs) {
|
||||
return res.status(400).json({ error: '"end" must be after "start"' });
|
||||
}
|
||||
|
||||
const format = (req.query.format || 'json').toLowerCase();
|
||||
if (!['json', 'csv'].includes(format)) {
|
||||
return res.status(400).json({ error: 'format must be "json" or "csv"' });
|
||||
}
|
||||
|
||||
const limit = Math.min(parseInt(req.query.limit, 10) || 5000, 5000);
|
||||
|
||||
const query = buildLogQLQuery({
|
||||
service: req.query.service,
|
||||
level: req.query.level,
|
||||
search: req.query.search,
|
||||
});
|
||||
|
||||
const entries = await queryLoki({
|
||||
query,
|
||||
start: startNs.toString(),
|
||||
end: endNs.toString(),
|
||||
limit,
|
||||
});
|
||||
|
||||
if (format === 'csv') {
|
||||
const csv = toCSV(entries);
|
||||
const filename = `xpeditis-logs-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
return res.send(csv);
|
||||
}
|
||||
|
||||
// JSON response
|
||||
res.json({
|
||||
total: entries.length,
|
||||
query,
|
||||
range: {
|
||||
from: new Date(Number(startNs / 1000000n)).toISOString(),
|
||||
to: new Date(Number(endNs / 1000000n)).toISOString(),
|
||||
},
|
||||
logs: entries,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[log-exporter] Export error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Start ────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[log-exporter] Listening on port ${PORT}`);
|
||||
console.log(`[log-exporter] Loki URL: ${LOKI_URL}`);
|
||||
console.log(`[log-exporter] API key protection: ${API_KEY ? 'enabled' : 'disabled'}`);
|
||||
});
|
||||
@ -50,7 +50,10 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: xpeditis-backend-dev
|
||||
ports:
|
||||
- "4001:4000"
|
||||
- "4000:4000"
|
||||
labels:
|
||||
logging: promtail
|
||||
logging.service: backend
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@ -58,6 +61,8 @@ services:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
# Force JSON logs in Docker so Promtail can parse them
|
||||
LOG_FORMAT: json
|
||||
PORT: 4000
|
||||
API_PREFIX: api/v1
|
||||
|
||||
@ -89,10 +94,10 @@ services:
|
||||
AWS_S3_BUCKET: xpeditis-csv-rates
|
||||
|
||||
# CORS - Allow both localhost (browser) and container network
|
||||
CORS_ORIGIN: "http://localhost:3001,http://localhost:4001"
|
||||
CORS_ORIGIN: "http://localhost:3000,http://localhost:4000"
|
||||
|
||||
# Application URL
|
||||
APP_URL: http://localhost:3001
|
||||
APP_URL: http://localhost:3000
|
||||
|
||||
# Security
|
||||
BCRYPT_ROUNDS: 10
|
||||
@ -102,19 +107,30 @@ services:
|
||||
RATE_LIMIT_TTL: 60
|
||||
RATE_LIMIT_MAX: 100
|
||||
|
||||
# SMTP (Brevo)
|
||||
SMTP_HOST: smtp-relay.brevo.com
|
||||
SMTP_PORT: 587
|
||||
SMTP_USER: 9637ef001@smtp-brevo.com
|
||||
SMTP_PASS: xsmtpsib-8d965bda028cd63bed868a119f9e0330485204bf9f4e1f92a3a11c8e61000722-xUYUSrGGxhMqlUcu
|
||||
SMTP_SECURE: "false"
|
||||
SMTP_FROM: noreply@xpeditis.com
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./apps/frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:4001
|
||||
NEXT_PUBLIC_API_URL: http://localhost:4000
|
||||
container_name: xpeditis-frontend-dev
|
||||
ports:
|
||||
- "3001:3000"
|
||||
- "3000:3000"
|
||||
labels:
|
||||
logging: promtail
|
||||
logging.service: frontend
|
||||
depends_on:
|
||||
- backend
|
||||
environment:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:4001
|
||||
NEXT_PUBLIC_API_URL: http://localhost:4000
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
254
docker-compose.full.yml
Normal file
254
docker-compose.full.yml
Normal file
@ -0,0 +1,254 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Xpeditis — Full Dev Stack (infrastructure + app + logging)
|
||||
#
|
||||
# Usage:
|
||||
# docker-compose -f docker-compose.full.yml up -d
|
||||
#
|
||||
# Exposed ports:
|
||||
# - Frontend: http://localhost:3000
|
||||
# - Backend: http://localhost:4000 (Swagger: /api/docs)
|
||||
# - Grafana: http://localhost:3030 (admin / xpeditis_grafana)
|
||||
# - Loki: http://localhost:3100 (internal)
|
||||
# - Promtail: http://localhost:9080 (internal)
|
||||
# - log-exporter: http://localhost:3200
|
||||
# - MinIO: http://localhost:9001 (console)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ─── Infrastructure ────────────────────────────────────────────────────────
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: xpeditis-postgres-dev
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_DB: xpeditis_dev
|
||||
POSTGRES_USER: xpeditis
|
||||
POSTGRES_PASSWORD: xpeditis_dev_password
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U xpeditis"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- xpeditis-network
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: xpeditis-redis-dev
|
||||
ports:
|
||||
- "6379:6379"
|
||||
command: redis-server --requirepass xpeditis_redis_password
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- xpeditis-network
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: xpeditis-minio-dev
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
command: server /data --console-address ":9001"
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
networks:
|
||||
- xpeditis-network
|
||||
|
||||
# ─── Application ───────────────────────────────────────────────────────────
|
||||
backend:
|
||||
build:
|
||||
context: ./apps/backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: xpeditis-backend-dev
|
||||
ports:
|
||||
- "4000:4000"
|
||||
labels:
|
||||
logging: promtail
|
||||
logging.service: backend
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
LOG_FORMAT: json
|
||||
PORT: 4000
|
||||
API_PREFIX: api/v1
|
||||
|
||||
# Database
|
||||
DATABASE_HOST: postgres
|
||||
DATABASE_PORT: 5432
|
||||
DATABASE_USER: xpeditis
|
||||
DATABASE_PASSWORD: xpeditis_dev_password
|
||||
DATABASE_NAME: xpeditis_dev
|
||||
DATABASE_SYNC: false
|
||||
DATABASE_LOGGING: true
|
||||
|
||||
# Redis
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
REDIS_PASSWORD: xpeditis_redis_password
|
||||
REDIS_DB: 0
|
||||
|
||||
# JWT
|
||||
JWT_SECRET: dev-secret-jwt-key-for-docker
|
||||
JWT_ACCESS_EXPIRATION: 15m
|
||||
JWT_REFRESH_EXPIRATION: 7d
|
||||
|
||||
# S3/MinIO
|
||||
AWS_S3_ENDPOINT: http://minio:9000
|
||||
AWS_REGION: us-east-1
|
||||
AWS_ACCESS_KEY_ID: minioadmin
|
||||
AWS_SECRET_ACCESS_KEY: minioadmin
|
||||
AWS_S3_BUCKET: xpeditis-csv-rates
|
||||
|
||||
# CORS
|
||||
CORS_ORIGIN: "http://localhost:3000,http://localhost:4000"
|
||||
|
||||
# Application URL
|
||||
APP_URL: http://localhost:3000
|
||||
|
||||
# Security
|
||||
BCRYPT_ROUNDS: 10
|
||||
SESSION_TIMEOUT_MS: 7200000
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_TTL: 60
|
||||
RATE_LIMIT_MAX: 100
|
||||
|
||||
# SMTP (Brevo)
|
||||
SMTP_HOST: smtp-relay.brevo.com
|
||||
SMTP_PORT: 587
|
||||
SMTP_USER: 9637ef001@smtp-brevo.com
|
||||
SMTP_PASS: xsmtpsib-8d965bda028cd63bed868a119f9e0330485204bf9f4e1f92a3a11c8e61000722-xUYUSrGGxhMqlUcu
|
||||
SMTP_SECURE: "false"
|
||||
SMTP_FROM: noreply@xpeditis.com
|
||||
networks:
|
||||
- xpeditis-network
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./apps/frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:4000
|
||||
container_name: xpeditis-frontend-dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
labels:
|
||||
logging: promtail
|
||||
logging.service: frontend
|
||||
depends_on:
|
||||
- backend
|
||||
environment:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:4000
|
||||
networks:
|
||||
- xpeditis-network
|
||||
|
||||
# ─── Logging Stack ─────────────────────────────────────────────────────────
|
||||
loki:
|
||||
image: grafana/loki:3.0.0
|
||||
container_name: xpeditis-loki
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3100:3100'
|
||||
volumes:
|
||||
- ./infra/logging/loki/loki-config.yml:/etc/loki/local-config.yaml:ro
|
||||
- loki_data:/loki
|
||||
command: -config.file=/etc/loki/local-config.yaml
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost:3100/ready || exit 1']
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- xpeditis-network
|
||||
|
||||
promtail:
|
||||
image: grafana/promtail:3.0.0
|
||||
container_name: xpeditis-promtail
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '9080:9080'
|
||||
volumes:
|
||||
- ./infra/logging/promtail/promtail-config.yml:/etc/promtail/config.yml:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
command: -config.file=/etc/promtail/config.yml
|
||||
depends_on:
|
||||
loki:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- xpeditis-network
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:11.0.0
|
||||
container_name: xpeditis-grafana
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3030:3000'
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: admin
|
||||
GF_SECURITY_ADMIN_PASSWORD: xpeditis_grafana
|
||||
GF_USERS_ALLOW_SIGN_UP: 'false'
|
||||
GF_AUTH_ANONYMOUS_ENABLED: 'false'
|
||||
GF_SERVER_ROOT_URL: http://localhost:3030
|
||||
GF_ANALYTICS_REPORTING_ENABLED: 'false'
|
||||
GF_ANALYTICS_CHECK_FOR_UPDATES: 'false'
|
||||
volumes:
|
||||
- ./infra/logging/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- grafana_data:/var/lib/grafana
|
||||
depends_on:
|
||||
loki:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost:3000/api/health || exit 1']
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- xpeditis-network
|
||||
|
||||
log-exporter:
|
||||
build:
|
||||
context: ./apps/log-exporter
|
||||
dockerfile: Dockerfile
|
||||
container_name: xpeditis-log-exporter
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3200:3200'
|
||||
environment:
|
||||
PORT: 3200
|
||||
LOKI_URL: http://loki:3100
|
||||
# Optional: set LOG_EXPORTER_API_KEY to require x-api-key header
|
||||
# LOG_EXPORTER_API_KEY: your-secret-key-here
|
||||
depends_on:
|
||||
loki:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- xpeditis-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
minio_data:
|
||||
loki_data:
|
||||
driver: local
|
||||
grafana_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
xpeditis-network:
|
||||
name: xpeditis-network
|
||||
115
docker-compose.logging.yml
Normal file
115
docker-compose.logging.yml
Normal file
@ -0,0 +1,115 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Xpeditis — Centralized Logging Stack
|
||||
#
|
||||
# Usage (standalone):
|
||||
# docker-compose -f docker-compose.yml -f docker-compose.logging.yml up -d
|
||||
#
|
||||
# Usage (full dev environment with logging):
|
||||
# docker-compose -f docker-compose.dev.yml -f docker-compose.logging.yml up -d
|
||||
#
|
||||
# Exposed ports:
|
||||
# - Grafana: http://localhost:3000 (admin / xpeditis_grafana)
|
||||
# - Loki: http://localhost:3100 (internal use only)
|
||||
# - Promtail: http://localhost:9080 (internal use only)
|
||||
# - log-exporter: http://localhost:3200 (export API)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
services:
|
||||
# ─── Loki — Log storage & query engine ────────────────────────────────────
|
||||
loki:
|
||||
image: grafana/loki:3.0.0
|
||||
container_name: xpeditis-loki
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3100:3100'
|
||||
volumes:
|
||||
- ./infra/logging/loki/loki-config.yml:/etc/loki/local-config.yaml:ro
|
||||
- loki_data:/loki
|
||||
command: -config.file=/etc/loki/local-config.yaml
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost:3100/ready || exit 1']
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- xpeditis-network
|
||||
|
||||
# ─── Promtail — Docker log collector ──────────────────────────────────────
|
||||
promtail:
|
||||
image: grafana/promtail:3.0.0
|
||||
container_name: xpeditis-promtail
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '9080:9080'
|
||||
volumes:
|
||||
- ./infra/logging/promtail/promtail-config.yml:/etc/promtail/config.yml:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
# Note: /var/lib/docker/containers is not needed with docker_sd_configs (uses Docker API)
|
||||
command: -config.file=/etc/promtail/config.yml
|
||||
depends_on:
|
||||
loki:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- xpeditis-network
|
||||
|
||||
# ─── Grafana — Visualization ───────────────────────────────────────────────
|
||||
grafana:
|
||||
image: grafana/grafana:11.0.0
|
||||
container_name: xpeditis-grafana
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3030:3000'
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: admin
|
||||
GF_SECURITY_ADMIN_PASSWORD: xpeditis_grafana
|
||||
GF_USERS_ALLOW_SIGN_UP: 'false'
|
||||
GF_AUTH_ANONYMOUS_ENABLED: 'false'
|
||||
GF_SERVER_ROOT_URL: http://localhost:3030
|
||||
# Disable telemetry
|
||||
GF_ANALYTICS_REPORTING_ENABLED: 'false'
|
||||
GF_ANALYTICS_CHECK_FOR_UPDATES: 'false'
|
||||
volumes:
|
||||
- ./infra/logging/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- grafana_data:/var/lib/grafana
|
||||
depends_on:
|
||||
loki:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost:3000/api/health || exit 1']
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- xpeditis-network
|
||||
|
||||
# ─── log-exporter — REST export API ───────────────────────────────────────
|
||||
log-exporter:
|
||||
build:
|
||||
context: ./apps/log-exporter
|
||||
dockerfile: Dockerfile
|
||||
container_name: xpeditis-log-exporter
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3200:3200'
|
||||
environment:
|
||||
PORT: 3200
|
||||
LOKI_URL: http://loki:3100
|
||||
# Optional: set LOG_EXPORTER_API_KEY to require x-api-key header
|
||||
# LOG_EXPORTER_API_KEY: your-secret-key-here
|
||||
depends_on:
|
||||
loki:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- xpeditis-network
|
||||
|
||||
volumes:
|
||||
loki_data:
|
||||
driver: local
|
||||
grafana_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
xpeditis-network:
|
||||
name: xpeditis-network
|
||||
# Re-uses the network created by docker-compose.yml / docker-compose.dev.yml.
|
||||
# If starting this stack alone, the network is created automatically.
|
||||
12
infra/logging/grafana/provisioning/dashboards/provider.yml
Normal file
12
infra/logging/grafana/provisioning/dashboards/provider.yml
Normal file
@ -0,0 +1,12 @@
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: Xpeditis Dashboards
|
||||
orgId: 1
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 30
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
path: /etc/grafana/provisioning/dashboards
|
||||
foldersFromFilesStructure: false
|
||||
636
infra/logging/grafana/provisioning/dashboards/xpeditis-logs.json
Normal file
636
infra/logging/grafana/provisioning/dashboards/xpeditis-logs.json
Normal file
@ -0,0 +1,636 @@
|
||||
{
|
||||
"title": "Xpeditis — Logs & Monitoring",
|
||||
"uid": "xpeditis-logs",
|
||||
"description": "Dashboard complet — logs backend/frontend, métriques HTTP, erreurs",
|
||||
"tags": ["xpeditis", "logs", "backend", "frontend"],
|
||||
"timezone": "browser",
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 38,
|
||||
"time": { "from": "now-1h", "to": "now" },
|
||||
"timepicker": {},
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"editable": true,
|
||||
"version": 1,
|
||||
"weekStart": "",
|
||||
"links": [],
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0,211,255,1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"name": "service",
|
||||
"label": "Service",
|
||||
"type": "query",
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"query": "label_values(service)",
|
||||
"refresh": 2,
|
||||
"sort": 1,
|
||||
"includeAll": true,
|
||||
"allValue": ".+",
|
||||
"multi": false,
|
||||
"hide": 0,
|
||||
"current": {},
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "level",
|
||||
"label": "Niveau",
|
||||
"type": "custom",
|
||||
"query": "All : .+, error : error, fatal : fatal, warn : warn, info : info, debug : debug",
|
||||
"includeAll": false,
|
||||
"multi": false,
|
||||
"hide": 0,
|
||||
"current": { "text": "All", "value": ".+" },
|
||||
"options": [
|
||||
{ "text": "All", "value": ".+", "selected": true },
|
||||
{ "text": "error", "value": "error", "selected": false },
|
||||
{ "text": "fatal", "value": "fatal", "selected": false },
|
||||
{ "text": "warn", "value": "warn", "selected": false },
|
||||
{ "text": "info", "value": "info", "selected": false },
|
||||
{ "text": "debug", "value": "debug", "selected": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"label": "Recherche",
|
||||
"type": "textbox",
|
||||
"query": "",
|
||||
"hide": 0,
|
||||
"current": { "text": "", "value": "" },
|
||||
"options": [{ "selected": true, "text": "", "value": "" }]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"panels": [
|
||||
|
||||
{
|
||||
"id": 100,
|
||||
"type": "row",
|
||||
"title": "Vue d'ensemble",
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }
|
||||
},
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Total logs",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 },
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"orientation": "auto",
|
||||
"textMode": "auto",
|
||||
"colorMode": "background",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "center"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] },
|
||||
"mappings": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"expr": "sum(count_over_time({service=~\"$service\"} [$__range]))",
|
||||
"legendFormat": "Total",
|
||||
"instant": true
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Erreurs & Fatal",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 },
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"orientation": "auto",
|
||||
"textMode": "auto",
|
||||
"colorMode": "background",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "center"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] },
|
||||
"mappings": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"expr": "sum(count_over_time({service=~\"$service\", level=~\"error|fatal\"} [$__range]))",
|
||||
"legendFormat": "Erreurs",
|
||||
"instant": true
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Warnings",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 },
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"orientation": "auto",
|
||||
"textMode": "auto",
|
||||
"colorMode": "background",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "center"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 1 }] },
|
||||
"mappings": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"expr": "sum(count_over_time({service=~\"$service\", level=\"warn\"} [$__range]))",
|
||||
"legendFormat": "Warnings",
|
||||
"instant": true
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Info",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 },
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"orientation": "auto",
|
||||
"textMode": "auto",
|
||||
"colorMode": "background",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "center"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "fixedColor": "blue", "mode": "fixed" },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] },
|
||||
"mappings": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"expr": "sum(count_over_time({service=~\"$service\", level=\"info\"} [$__range]))",
|
||||
"legendFormat": "Info",
|
||||
"instant": true
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Requêtes HTTP 5xx",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 },
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"orientation": "auto",
|
||||
"textMode": "auto",
|
||||
"colorMode": "background",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "center"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] },
|
||||
"mappings": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 500 [$__range]))",
|
||||
"legendFormat": "5xx",
|
||||
"instant": true
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 6,
|
||||
"title": "Temps réponse moyen (ms)",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 },
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"orientation": "auto",
|
||||
"textMode": "auto",
|
||||
"colorMode": "background",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "center"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"unit": "ms",
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 500 }, { "color": "red", "value": 2000 }] },
|
||||
"mappings": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"expr": "avg(avg_over_time({service=\"backend\"} | json | unwrap responseTime [$__range]))",
|
||||
"legendFormat": "Avg",
|
||||
"instant": true
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 200,
|
||||
"type": "row",
|
||||
"title": "Volume des logs",
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }
|
||||
},
|
||||
|
||||
{
|
||||
"id": 7,
|
||||
"title": "Volume par niveau",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 14, "x": 0, "y": 6 },
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"options": {
|
||||
"legend": { "calcs": ["sum"], "displayMode": "list", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"drawStyle": "bars",
|
||||
"fillOpacity": 80,
|
||||
"stacking": { "group": "A", "mode": "normal" },
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"showPoints": "never",
|
||||
"spanNulls": false
|
||||
},
|
||||
"unit": "short",
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
|
||||
},
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byName", "options": "error" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] },
|
||||
{ "matcher": { "id": "byName", "options": "fatal" }, "properties": [{ "id": "color", "value": { "fixedColor": "dark-red", "mode": "fixed" } }] },
|
||||
{ "matcher": { "id": "byName", "options": "warn" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] },
|
||||
{ "matcher": { "id": "byName", "options": "info" }, "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] },
|
||||
{ "matcher": { "id": "byName", "options": "debug" }, "properties": [{ "id": "color", "value": { "fixedColor": "gray", "mode": "fixed" } }] }
|
||||
]
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"expr": "sum by (level) (count_over_time({service=~\"$service\", level=~\".+\"} [$__interval]))",
|
||||
"legendFormat": "{{level}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 8,
|
||||
"title": "Volume par service",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 10, "x": 14, "y": 6 },
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"options": {
|
||||
"legend": { "calcs": ["sum"], "displayMode": "list", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"drawStyle": "bars",
|
||||
"fillOpacity": 60,
|
||||
"stacking": { "group": "A", "mode": "normal" },
|
||||
"lineWidth": 1,
|
||||
"showPoints": "never",
|
||||
"spanNulls": false
|
||||
},
|
||||
"unit": "short",
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"expr": "sum by (service) (count_over_time({service=~\"$service\"} [$__interval]))",
|
||||
"legendFormat": "{{service}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 300,
|
||||
"type": "row",
|
||||
"title": "HTTP — Backend",
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }
|
||||
},
|
||||
|
||||
{
|
||||
"id": 9,
|
||||
"title": "Taux d'erreur HTTP",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 },
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"options": {
|
||||
"legend": { "calcs": ["max", "mean"], "displayMode": "list", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 20,
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"showPoints": "never",
|
||||
"spanNulls": false
|
||||
},
|
||||
"unit": "short",
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
|
||||
},
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byName", "options": "5xx" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] },
|
||||
{ "matcher": { "id": "byName", "options": "4xx" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] },
|
||||
{ "matcher": { "id": "byName", "options": "2xx" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] }
|
||||
]
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 500 [$__interval]))",
|
||||
"legendFormat": "5xx"
|
||||
},
|
||||
{
|
||||
"refId": "B",
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 400 < 500 [$__interval]))",
|
||||
"legendFormat": "4xx"
|
||||
},
|
||||
{
|
||||
"refId": "C",
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 200 < 300 [$__interval]))",
|
||||
"legendFormat": "2xx"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 10,
|
||||
"title": "Temps de réponse (ms)",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 },
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"options": {
|
||||
"legend": { "calcs": ["max", "mean"], "displayMode": "list", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"showPoints": "never",
|
||||
"spanNulls": false
|
||||
},
|
||||
"unit": "ms",
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 500 }, { "color": "red", "value": 2000 }] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"expr": "avg(avg_over_time({service=\"backend\"} | json | unwrap responseTime [$__interval]))",
|
||||
"legendFormat": "Moy"
|
||||
},
|
||||
{
|
||||
"refId": "B",
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"expr": "max(max_over_time({service=\"backend\"} | json | unwrap responseTime [$__interval]))",
|
||||
"legendFormat": "Max"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 400,
|
||||
"type": "row",
|
||||
"title": "Logs — Flux en direct",
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 }
|
||||
},
|
||||
|
||||
{
|
||||
"id": 11,
|
||||
"title": "Backend — Logs",
|
||||
"type": "logs",
|
||||
"gridPos": { "h": 14, "w": 12, "x": 0, "y": 24 },
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"options": {
|
||||
"dedupStrategy": "none",
|
||||
"enableLogDetails": true,
|
||||
"prettifyLogMessage": true,
|
||||
"showCommonLabels": false,
|
||||
"showLabels": false,
|
||||
"showTime": true,
|
||||
"sortOrder": "Descending",
|
||||
"wrapLogMessage": false
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"expr": "{service=\"backend\", level=~\"$level\"} |= \"$search\"",
|
||||
"legendFormat": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 12,
|
||||
"title": "Frontend — Logs",
|
||||
"type": "logs",
|
||||
"gridPos": { "h": 14, "w": 12, "x": 12, "y": 24 },
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"options": {
|
||||
"dedupStrategy": "none",
|
||||
"enableLogDetails": true,
|
||||
"prettifyLogMessage": true,
|
||||
"showCommonLabels": false,
|
||||
"showLabels": false,
|
||||
"showTime": true,
|
||||
"sortOrder": "Descending",
|
||||
"wrapLogMessage": false
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"expr": "{service=\"frontend\", level=~\"$level\"} |= \"$search\"",
|
||||
"legendFormat": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 500,
|
||||
"type": "row",
|
||||
"title": "Tous les logs filtrés",
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 38 }
|
||||
},
|
||||
|
||||
{
|
||||
"id": 13,
|
||||
"title": "Flux filtré — $service / $level",
|
||||
"description": "Utilisez les variables en haut pour filtrer par service, niveau ou mot-clé",
|
||||
"type": "logs",
|
||||
"gridPos": { "h": 14, "w": 24, "x": 0, "y": 39 },
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"options": {
|
||||
"dedupStrategy": "none",
|
||||
"enableLogDetails": true,
|
||||
"prettifyLogMessage": true,
|
||||
"showCommonLabels": false,
|
||||
"showLabels": true,
|
||||
"showTime": true,
|
||||
"sortOrder": "Descending",
|
||||
"wrapLogMessage": true
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"expr": "{service=~\"$service\", level=~\"$level\"} |= \"$search\"",
|
||||
"legendFormat": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 600,
|
||||
"type": "row",
|
||||
"title": "Erreurs & Exceptions",
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 53 }
|
||||
},
|
||||
|
||||
{
|
||||
"id": 14,
|
||||
"title": "Erreurs — Backend",
|
||||
"type": "logs",
|
||||
"gridPos": { "h": 10, "w": 12, "x": 0, "y": 54 },
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"options": {
|
||||
"dedupStrategy": "signature",
|
||||
"enableLogDetails": true,
|
||||
"prettifyLogMessage": true,
|
||||
"showCommonLabels": false,
|
||||
"showLabels": false,
|
||||
"showTime": true,
|
||||
"sortOrder": "Descending",
|
||||
"wrapLogMessage": true
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"expr": "{service=\"backend\", level=~\"error|fatal\"}",
|
||||
"legendFormat": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 15,
|
||||
"title": "Erreurs — Frontend",
|
||||
"type": "logs",
|
||||
"gridPos": { "h": 10, "w": 12, "x": 12, "y": 54 },
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"options": {
|
||||
"dedupStrategy": "signature",
|
||||
"enableLogDetails": true,
|
||||
"prettifyLogMessage": true,
|
||||
"showCommonLabels": false,
|
||||
"showLabels": false,
|
||||
"showTime": true,
|
||||
"sortOrder": "Descending",
|
||||
"wrapLogMessage": true
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
|
||||
"expr": "{service=\"frontend\", level=~\"error|fatal\"}",
|
||||
"legendFormat": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
]
|
||||
}
|
||||
19
infra/logging/grafana/provisioning/datasources/loki.yml
Normal file
19
infra/logging/grafana/provisioning/datasources/loki.yml
Normal file
@ -0,0 +1,19 @@
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Loki
|
||||
uid: loki-xpeditis
|
||||
type: loki
|
||||
access: proxy
|
||||
url: http://loki:3100
|
||||
isDefault: true
|
||||
version: 1
|
||||
editable: false
|
||||
jsonData:
|
||||
maxLines: 1000
|
||||
timeout: 60
|
||||
derivedFields:
|
||||
- datasourceUid: ''
|
||||
matcherRegex: '"reqId":"([^"]+)"'
|
||||
name: RequestID
|
||||
url: ''
|
||||
62
infra/logging/loki/loki-config.yml
Normal file
62
infra/logging/loki/loki-config.yml
Normal file
@ -0,0 +1,62 @@
|
||||
auth_enabled: false
|
||||
|
||||
server:
|
||||
http_listen_port: 3100
|
||||
grpc_listen_port: 9096
|
||||
log_level: warn
|
||||
|
||||
# Memberlist-based ring coordination — required for single-node Loki 3.x
|
||||
memberlist:
|
||||
bind_port: 7946
|
||||
join_members:
|
||||
- 127.0.0.1:7946
|
||||
|
||||
common:
|
||||
instance_addr: 127.0.0.1
|
||||
path_prefix: /loki
|
||||
storage:
|
||||
filesystem:
|
||||
chunks_directory: /loki/chunks
|
||||
rules_directory: /loki/rules
|
||||
replication_factor: 1
|
||||
ring:
|
||||
kvstore:
|
||||
store: memberlist
|
||||
|
||||
schema_config:
|
||||
configs:
|
||||
- from: 2020-10-24
|
||||
store: tsdb
|
||||
object_store: filesystem
|
||||
schema: v13
|
||||
index:
|
||||
prefix: index_
|
||||
period: 24h
|
||||
|
||||
limits_config:
|
||||
allow_structured_metadata: true
|
||||
volume_enabled: true
|
||||
retention_period: 744h # 31 days
|
||||
reject_old_samples: true
|
||||
reject_old_samples_max_age: 168h # Accept logs up to 7 days old
|
||||
ingestion_rate_mb: 16
|
||||
ingestion_burst_size_mb: 32
|
||||
max_entries_limit_per_query: 5000
|
||||
|
||||
compactor:
|
||||
working_directory: /loki/compactor
|
||||
compaction_interval: 10m
|
||||
retention_enabled: true
|
||||
retention_delete_delay: 2h
|
||||
retention_delete_worker_count: 150
|
||||
delete_request_store: filesystem
|
||||
|
||||
query_range:
|
||||
results_cache:
|
||||
cache:
|
||||
embedded_cache:
|
||||
enabled: true
|
||||
max_size_mb: 100
|
||||
|
||||
analytics:
|
||||
reporting_enabled: false
|
||||
70
infra/logging/promtail/promtail-config.yml
Normal file
70
infra/logging/promtail/promtail-config.yml
Normal file
@ -0,0 +1,70 @@
|
||||
server:
|
||||
http_listen_port: 9080
|
||||
grpc_listen_port: 0
|
||||
log_level: warn
|
||||
|
||||
positions:
|
||||
filename: /tmp/positions.yaml
|
||||
|
||||
clients:
|
||||
- url: http://loki:3100/loki/api/v1/push
|
||||
batchwait: 1s
|
||||
batchsize: 1048576
|
||||
timeout: 10s
|
||||
|
||||
scrape_configs:
|
||||
# ─── Docker container log collection (Mac-compatible via Docker socket API) ─
|
||||
- job_name: docker
|
||||
docker_sd_configs:
|
||||
- host: unix:///var/run/docker.sock
|
||||
refresh_interval: 5s
|
||||
filters:
|
||||
# Only collect containers with label: logging=promtail
|
||||
# Add this label to backend and frontend in docker-compose.dev.yml
|
||||
- name: label
|
||||
values: ['logging=promtail']
|
||||
|
||||
relabel_configs:
|
||||
# Use docker-compose service name as the "service" label
|
||||
- source_labels: ['__meta_docker_container_label_com_docker_compose_service']
|
||||
target_label: service
|
||||
# Keep container name for context
|
||||
- source_labels: ['__meta_docker_container_name']
|
||||
regex: '/?(.*)'
|
||||
replacement: '${1}'
|
||||
target_label: container
|
||||
# Log stream (stdout / stderr)
|
||||
- source_labels: ['__meta_docker_container_log_stream']
|
||||
target_label: stream
|
||||
|
||||
pipeline_stages:
|
||||
# Drop entries older than 15 min to avoid replaying full container log history
|
||||
- drop:
|
||||
older_than: 15m
|
||||
drop_counter_reason: entry_too_old
|
||||
|
||||
# Drop noisy health-check / ping lines
|
||||
- drop:
|
||||
expression: 'GET /(health|metrics|minio/health)'
|
||||
|
||||
# Try to parse JSON (NestJS/pino output)
|
||||
- json:
|
||||
expressions:
|
||||
level: level
|
||||
msg: msg
|
||||
context: context
|
||||
reqId: reqId
|
||||
|
||||
# Promote parsed fields as Loki labels
|
||||
- labels:
|
||||
level:
|
||||
context:
|
||||
|
||||
# Map pino numeric levels to strings
|
||||
- template:
|
||||
source: level
|
||||
template: >-
|
||||
{{ if eq .Value "10" }}trace{{ else if eq .Value "20" }}debug{{ else if eq .Value "30" }}info{{ else if eq .Value "40" }}warn{{ else if eq .Value "50" }}error{{ else if eq .Value "60" }}fatal{{ else }}{{ .Value }}{{ end }}
|
||||
|
||||
- labels:
|
||||
level:
|
||||
Loading…
Reference in New Issue
Block a user