xpeditis2.0/apps/frontend/app/dashboard/admin/logs/page.tsx
David d65cb721b5
Some checks are pending
CD Production (Hetzner k3s) / Promote Images (preprod → prod) (push) Waiting to run
CD Production (Hetzner k3s) / Deploy to k3s (xpeditis-prod) (push) Blocked by required conditions
CD Production (Hetzner k3s) / Smoke Tests (push) Blocked by required conditions
CD Production (Hetzner k3s) / Deployment Summary (push) Blocked by required conditions
CD Production (Hetzner k3s) / Notify Success (push) Blocked by required conditions
CD Production (Hetzner k3s) / Notify Failure (push) Blocked by required conditions
chore: sync full codebase from cicd branch
Aligns main with the complete application codebase (cicd branch).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:56:44 +02:00

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>
);
}