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
|
// Logging
|
||||||
LoggerModule.forRootAsync({
|
LoggerModule.forRootAsync({
|
||||||
useFactory: (configService: ConfigService) => ({
|
useFactory: (configService: ConfigService) => {
|
||||||
pinoHttp: {
|
const isDev = configService.get('NODE_ENV') === 'development';
|
||||||
transport:
|
// LOG_FORMAT=json forces structured JSON output (e.g. inside Docker + Promtail)
|
||||||
configService.get('NODE_ENV') === 'development'
|
const forceJson = configService.get('LOG_FORMAT') === 'json';
|
||||||
|
const usePretty = isDev && !forceJson;
|
||||||
|
|
||||||
|
return {
|
||||||
|
pinoHttp: {
|
||||||
|
transport: usePretty
|
||||||
? {
|
? {
|
||||||
target: 'pino-pretty',
|
target: 'pino-pretty',
|
||||||
options: {
|
options: {
|
||||||
@ -85,9 +90,21 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: undefined,
|
: 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],
|
inject: [ConfigService],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|||||||
import { IsEmail, IsString, MinLength, IsEnum, IsOptional } from 'class-validator';
|
import { IsEmail, IsString, MinLength, IsEnum, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
export enum InvitationRole {
|
export enum InvitationRole {
|
||||||
ADMIN = 'ADMIN',
|
|
||||||
MANAGER = 'MANAGER',
|
MANAGER = 'MANAGER',
|
||||||
USER = 'USER',
|
USER = 'USER',
|
||||||
VIEWER = 'VIEWER',
|
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: '',
|
email: '',
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
role: 'USER' as 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER',
|
role: 'USER' as 'MANAGER' | 'USER' | 'VIEWER',
|
||||||
});
|
});
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
@ -635,12 +635,8 @@ export default function UsersManagementPage() {
|
|||||||
>
|
>
|
||||||
<option value="USER">Utilisateur</option>
|
<option value="USER">Utilisateur</option>
|
||||||
<option value="MANAGER">Manager</option>
|
<option value="MANAGER">Manager</option>
|
||||||
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Administrateur</option>}
|
|
||||||
<option value="VIEWER">Lecteur</option>
|
<option value="VIEWER">Lecteur</option>
|
||||||
</select>
|
</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>
|
||||||
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
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 {
|
interface AdminMenuItem {
|
||||||
name: string;
|
name: string;
|
||||||
@ -43,6 +43,12 @@ const adminMenuItems: AdminMenuItem[] = [
|
|||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
description: 'Importer et gérer les tarifs CSV',
|
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() {
|
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
|
dockerfile: Dockerfile
|
||||||
container_name: xpeditis-backend-dev
|
container_name: xpeditis-backend-dev
|
||||||
ports:
|
ports:
|
||||||
- "4001:4000"
|
- "4000:4000"
|
||||||
|
labels:
|
||||||
|
logging: promtail
|
||||||
|
logging.service: backend
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@ -58,6 +61,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
|
# Force JSON logs in Docker so Promtail can parse them
|
||||||
|
LOG_FORMAT: json
|
||||||
PORT: 4000
|
PORT: 4000
|
||||||
API_PREFIX: api/v1
|
API_PREFIX: api/v1
|
||||||
|
|
||||||
@ -89,10 +94,10 @@ services:
|
|||||||
AWS_S3_BUCKET: xpeditis-csv-rates
|
AWS_S3_BUCKET: xpeditis-csv-rates
|
||||||
|
|
||||||
# CORS - Allow both localhost (browser) and container network
|
# 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
|
# Application URL
|
||||||
APP_URL: http://localhost:3001
|
APP_URL: http://localhost:3000
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
BCRYPT_ROUNDS: 10
|
BCRYPT_ROUNDS: 10
|
||||||
@ -102,19 +107,30 @@ services:
|
|||||||
RATE_LIMIT_TTL: 60
|
RATE_LIMIT_TTL: 60
|
||||||
RATE_LIMIT_MAX: 100
|
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:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./apps/frontend
|
context: ./apps/frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
NEXT_PUBLIC_API_URL: http://localhost:4001
|
NEXT_PUBLIC_API_URL: http://localhost:4000
|
||||||
container_name: xpeditis-frontend-dev
|
container_name: xpeditis-frontend-dev
|
||||||
ports:
|
ports:
|
||||||
- "3001:3000"
|
- "3000:3000"
|
||||||
|
labels:
|
||||||
|
logging: promtail
|
||||||
|
logging.service: frontend
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
environment:
|
environment:
|
||||||
NEXT_PUBLIC_API_URL: http://localhost:4001
|
NEXT_PUBLIC_API_URL: http://localhost:4000
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
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