Some checks failed
CD Preprod / Unit Tests (${{ matrix.app }}) (backend) (push) Blocked by required conditions
CD Preprod / Unit Tests (${{ matrix.app }}) (frontend) (push) Blocked by required conditions
CD Preprod / Integration Tests (push) Blocked by required conditions
CD Preprod / Build & Push Backend (push) Blocked by required conditions
CD Preprod / Build & Push Frontend (push) Blocked by required conditions
CD Preprod / Deploy to Preprod (push) Blocked by required conditions
CD Preprod / Smoke Tests (push) Blocked by required conditions
CD Preprod / Deployment Summary (push) Blocked by required conditions
CD Preprod / Notify Success (push) Blocked by required conditions
CD Preprod / Notify Failure (push) Blocked by required conditions
CD Preprod / Quality (${{ matrix.app }}) (backend) (push) Has been cancelled
CD Preprod / Quality (${{ matrix.app }}) (frontend) (push) Has been cancelled
Aligns preprod with the complete application codebase (cicd branch). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
549 lines
21 KiB
TypeScript
549 lines
21 KiB
TypeScript
'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>
|
|
);
|
|
}
|