Some checks failed
SourceFinder CI/CD Pipeline / Code Quality & Linting (push) Has been cancelled
SourceFinder CI/CD Pipeline / Unit Tests (push) Has been cancelled
SourceFinder CI/CD Pipeline / Security Tests (push) Has been cancelled
SourceFinder CI/CD Pipeline / Integration Tests (push) Has been cancelled
SourceFinder CI/CD Pipeline / Performance Tests (push) Has been cancelled
SourceFinder CI/CD Pipeline / Code Coverage Report (push) Has been cancelled
SourceFinder CI/CD Pipeline / Build & Deployment Validation (16.x) (push) Has been cancelled
SourceFinder CI/CD Pipeline / Build & Deployment Validation (18.x) (push) Has been cancelled
SourceFinder CI/CD Pipeline / Build & Deployment Validation (20.x) (push) Has been cancelled
SourceFinder CI/CD Pipeline / Regression Tests (push) Has been cancelled
SourceFinder CI/CD Pipeline / Security Audit (push) Has been cancelled
SourceFinder CI/CD Pipeline / Notify Results (push) Has been cancelled
- Architecture modulaire avec injection de dépendances - Système de scoring intelligent multi-facteurs (spécificité, fraîcheur, qualité, réutilisation) - Moteur anti-injection 4 couches (preprocessing, patterns, sémantique, pénalités) - API REST complète avec validation et rate limiting - Repository JSON avec index mémoire et backup automatique - Provider LLM modulaire pour génération de contenu - Suite de tests complète (Jest) : * Tests unitaires pour sécurité et scoring * Tests d'intégration API end-to-end * Tests de sécurité avec simulation d'attaques * Tests de performance et charge - Pipeline CI/CD avec GitHub Actions - Logging structuré et monitoring - Configuration ESLint et environnement de test 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
921 lines
37 KiB
HTML
921 lines
37 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>SEO Generator - Logs en temps réel</title>
|
|
<style>
|
|
body {
|
|
font-family: 'Courier New', monospace;
|
|
background: #1e1e1e;
|
|
color: #ffffff;
|
|
margin: 0;
|
|
padding: 4px;
|
|
}
|
|
.header {
|
|
background: #2d2d30;
|
|
padding: 4px;
|
|
border-radius: 2px;
|
|
margin-bottom: 4px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.header-left h1 {
|
|
margin: 0;
|
|
font-size: 12px;
|
|
}
|
|
.header-right {
|
|
display: flex;
|
|
gap: 4px;
|
|
align-items: center;
|
|
}
|
|
.status {
|
|
display: inline-block;
|
|
padding: 2px 4px;
|
|
border-radius: 1px;
|
|
font-size: 9px;
|
|
font-weight: bold;
|
|
}
|
|
.status.connected { background: #28a745; }
|
|
.status.disconnected { background: #dc3545; }
|
|
.status.connecting { background: #ffc107; color: #000; }
|
|
|
|
.logs-container {
|
|
height: calc(100vh - 88px);
|
|
overflow-y: auto;
|
|
background: #0d1117;
|
|
border: 1px solid #30363d;
|
|
border-radius: 2px;
|
|
padding: 4px;
|
|
}
|
|
.log-entry {
|
|
padding: 2px 0;
|
|
border-bottom: 1px solid #21262d;
|
|
font-size: 12px;
|
|
line-height: 1.2;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
cursor: pointer;
|
|
}
|
|
.log-entry.unwrapped {
|
|
white-space: pre-wrap;
|
|
overflow: visible;
|
|
text-overflow: unset;
|
|
background: rgba(88, 166, 255, 0.05);
|
|
border-left: 2px solid #58a6ff;
|
|
padding-left: 4px;
|
|
}
|
|
.log-entry:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.log-entry.trace {
|
|
background: rgba(31, 111, 235, 0.1);
|
|
padding-left: 1px;
|
|
border-left: 2px solid #1f6feb;
|
|
}
|
|
.log-entry.trace.span-start {
|
|
border-left-color: #28a745;
|
|
}
|
|
.log-entry.trace.span-end {
|
|
border-left-color: #17a2b8;
|
|
}
|
|
.log-entry.trace.span-error {
|
|
border-left-color: #dc3545;
|
|
background: rgba(220, 53, 69, 0.1);
|
|
}
|
|
.log-entry.stack-trace {
|
|
background: rgba(220, 53, 69, 0.05);
|
|
padding-left: 1px;
|
|
color: #f85149;
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 10px;
|
|
border-left: 2px solid #dc3545;
|
|
}
|
|
.log-details {
|
|
margin-top: 4px;
|
|
padding: 4px;
|
|
background: rgba(139, 148, 158, 0.1);
|
|
border-radius: 2px;
|
|
font-size: 9px;
|
|
color: #8b949e;
|
|
display: none;
|
|
}
|
|
.show-details .log-details {
|
|
display: block;
|
|
}
|
|
.details-toggle {
|
|
background: none;
|
|
color: #58a6ff;
|
|
border: 1px solid #58a6ff;
|
|
padding: 1px 1px;
|
|
font-size: 8px;
|
|
margin-right: 4px;
|
|
}
|
|
.details-toggle:hover {
|
|
background: rgba(88, 166, 255, 0.1);
|
|
}
|
|
.unwrap-toggle {
|
|
background: none;
|
|
color: #f79009;
|
|
border: 1px solid #f79009;
|
|
padding: 1px 1px;
|
|
font-size: 8px;
|
|
margin-right: 4px;
|
|
}
|
|
.unwrap-toggle:hover {
|
|
background: rgba(247, 144, 9, 0.1);
|
|
}
|
|
.search-container {
|
|
margin-bottom: 3px;
|
|
display: flex;
|
|
gap: 4px;
|
|
align-items: center;
|
|
}
|
|
.search-input {
|
|
flex-grow: 1;
|
|
background: #21262d;
|
|
border: 1px solid #30363d;
|
|
color: #f0f6fc;
|
|
padding: 4px 6px;
|
|
border-radius: 2px;
|
|
font-size: 11px;
|
|
}
|
|
.search-input:focus {
|
|
outline: none;
|
|
border-color: #58a6ff;
|
|
background: #0d1117;
|
|
}
|
|
.search-info {
|
|
color: #7d8590;
|
|
font-size: 10px;
|
|
min-width: 80px;
|
|
}
|
|
.log-entry.search-match {
|
|
background: rgba(255, 193, 7, 0.2);
|
|
border-left: 3px solid #ffc107;
|
|
}
|
|
.log-entry.search-current {
|
|
background: rgba(255, 193, 7, 0.4);
|
|
border-left: 3px solid #ffc107;
|
|
}
|
|
.search-highlight {
|
|
background: #ffc107;
|
|
color: #000;
|
|
padding: 1px 2px;
|
|
border-radius: 2px;
|
|
}
|
|
.timestamp {
|
|
color: #7d8590;
|
|
margin-right: 1px;
|
|
font-size: 11px;
|
|
}
|
|
.level {
|
|
font-weight: bold;
|
|
margin-right: 1px;
|
|
padding: 1px 1px;
|
|
border-radius: 2px;
|
|
font-size: 11px;
|
|
min-width: 32px;
|
|
}
|
|
.level.INFO { background: #1f6feb; }
|
|
.level.WARN, .level.WARNING { background: #d29922; }
|
|
.level.ERROR { background: #da3633; }
|
|
.level.DEBUG { background: #8b949e; }
|
|
.level.TRACE { background: #238636; }
|
|
.level.PROMPT { background: #8b5cf6; }
|
|
.level.LLM { background: #f97316; }
|
|
button {
|
|
background: #238636;
|
|
color: white;
|
|
border: none;
|
|
padding: 3px 6px;
|
|
border-radius: 2px;
|
|
cursor: pointer;
|
|
font-size: 10px;
|
|
}
|
|
button:hover { background: #2ea043; }
|
|
button:disabled { background: #6e7781; cursor: not-allowed; }
|
|
.filter-toggles {
|
|
display: flex;
|
|
gap: 2px;
|
|
align-items: center;
|
|
margin-left: 6px;
|
|
}
|
|
.filter-toggle {
|
|
background: #21262d;
|
|
border: 1px solid #30363d;
|
|
color: #f0f6fc;
|
|
padding: 2px 4px;
|
|
border-radius: 1px;
|
|
cursor: pointer;
|
|
font-size: 9px;
|
|
min-width: 40px;
|
|
text-align: center;
|
|
}
|
|
.filter-toggle.active.trace { background: #238636; border-color: #238636; }
|
|
.filter-toggle.active.info { background: #1f6feb; border-color: #1f6feb; }
|
|
.filter-toggle.active.debug { background: #8b949e; border-color: #8b949e; }
|
|
.filter-toggle.active.warn { background: #d29922; border-color: #d29922; }
|
|
.filter-toggle.active.error { background: #da3633; border-color: #da3633; }
|
|
.filter-toggle.active.prompt { background: #8b5cf6; border-color: #8b5cf6; }
|
|
.filter-toggle:hover { background: #30363d; }
|
|
.log-entry.hidden-by-filter { display: none !important; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<div class="header-left">
|
|
<h1>SEO Generator - Logs temps réel</h1>
|
|
<span id="status" class="status connecting">Connexion...</span>
|
|
<span style="margin-left: 15px; font-size: 12px;">Port: <strong>8082</strong></span>
|
|
<br>
|
|
<button onclick="toggleGlobalDetails()" id="detailsBtn">Mode détaillé: OFF</button>
|
|
<button onclick="toggleLineUnwrap()" id="lineUnwrapBtn">Unwrap ligne: OFF</button>
|
|
</div>
|
|
<div class="header-right">
|
|
<div class="filter-toggles">
|
|
<span style="color: #7d8590; font-size: 11px;">Filtres:</span>
|
|
<button class="filter-toggle active trace" onclick="toggleLevelFilter('trace')" id="traceFilter">TRACE</button>
|
|
<button class="filter-toggle active info" onclick="toggleLevelFilter('info')" id="infoFilter">INFO</button>
|
|
<button class="filter-toggle active debug" onclick="toggleLevelFilter('debug')" id="debugFilter">DEBUG</button>
|
|
<button class="filter-toggle active warn" onclick="toggleLevelFilter('warn')" id="warnFilter">WARN</button>
|
|
<button class="filter-toggle active error" onclick="toggleLevelFilter('error')" id="errorFilter">ERROR</button>
|
|
<button class="filter-toggle active prompt" onclick="toggleLevelFilter('prompt')" id="promptFilter">PROMPT</button>
|
|
<button class="filter-toggle active llm" onclick="toggleLevelFilter('llm')" id="llmFilter">LLM</button>
|
|
</div>
|
|
<button onclick="clearLogs()">Effacer</button>
|
|
<button onclick="toggleAutoScroll()" id="autoScrollBtn">Auto-scroll: ON</button>
|
|
<button onclick="reconnect()" id="reconnectBtn">Reconnecter</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="search-container">
|
|
<input type="text" class="search-input" id="searchInput" placeholder="Rechercher dans les logs... (Ctrl+F)">
|
|
<div class="search-info" id="searchInfo">0 résultats</div>
|
|
<button onclick="searchPrevious()" id="searchPrevBtn" disabled>⬆ Précédent</button>
|
|
<button onclick="searchNext()" id="searchNextBtn" disabled>⬇ Suivant</button>
|
|
<button onclick="clearSearch()" id="clearSearchBtn">✕</button>
|
|
</div>
|
|
|
|
<div class="logs-container" id="logsContainer">
|
|
<div class="log-entry">
|
|
<span class="timestamp">--:--:--</span>
|
|
<span class="level INFO">INFO</span>
|
|
En attente des logs...
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let ws;
|
|
let autoScroll = true;
|
|
const logsContainer = document.getElementById('logsContainer');
|
|
const statusElement = document.getElementById('status');
|
|
|
|
// Variables de recherche
|
|
let searchMatches = [];
|
|
let currentMatchIndex = -1;
|
|
let searchTerm = '';
|
|
|
|
// Variables de filtrage
|
|
let levelFilters = {
|
|
trace: true,
|
|
info: true,
|
|
debug: true,
|
|
warn: true,
|
|
warning: true,
|
|
error: true,
|
|
prompt: true,
|
|
llm: true
|
|
};
|
|
|
|
// Récupérer le fichier de log depuis l'URL
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const logFile = urlParams.get('file');
|
|
console.log('🌐 URL params:', window.location.search, 'logFile:', logFile);
|
|
|
|
if (logFile) {
|
|
// Mode fichier : charger le fichier spécifié
|
|
console.log('📁 MODE FICHIER activé pour:', logFile);
|
|
document.title = `SEO Generator - Logs: ${logFile}`;
|
|
document.querySelector('h1').textContent = `Logs: ${logFile}`;
|
|
loadLogFile(logFile);
|
|
} else {
|
|
// Mode temps réel : WebSocket comme avant
|
|
console.log('⚡ MODE WEBSOCKET activé - pas de paramètre file');
|
|
connect();
|
|
}
|
|
|
|
async function loadLogFile(filename) {
|
|
try {
|
|
statusElement.textContent = `Chargement ${filename}...`;
|
|
statusElement.className = 'status connecting';
|
|
|
|
// Utiliser file:// pour lire directement le fichier local
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = '.log';
|
|
input.style.display = 'none';
|
|
|
|
input.onchange = function(event) {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = function(e) {
|
|
const logContent = e.target.result;
|
|
const lines = logContent.split('\n').filter(line => line.trim());
|
|
|
|
statusElement.textContent = `Fichier chargé (${lines.length} lignes)`;
|
|
statusElement.className = 'status connected';
|
|
|
|
// Parser et afficher chaque ligne
|
|
lines.forEach(line => {
|
|
try {
|
|
const logData = JSON.parse(line);
|
|
const timestamp = new Date(logData.time).toISOString();
|
|
const level = normalizeLevelName(logData.level);
|
|
addLogEntry(logData.msg || logData.message || line, level, timestamp, line);
|
|
} catch (error) {
|
|
// Ligne non-JSON, afficher telle quelle
|
|
addLogEntry(line, 'INFO', new Date().toISOString(), line);
|
|
}
|
|
});
|
|
};
|
|
reader.readAsText(file);
|
|
};
|
|
|
|
// Si un nom de fichier est spécifié, tenter de le charger depuis logs/
|
|
if (filename) {
|
|
try {
|
|
const response = await fetch(`logs/${filename}`);
|
|
if (response.ok) {
|
|
const logContent = await response.text();
|
|
const lines = logContent.split('\n').filter(line => line.trim());
|
|
|
|
statusElement.textContent = `Fichier chargé (${lines.length} lignes)`;
|
|
statusElement.className = 'status connected';
|
|
|
|
lines.forEach(line => {
|
|
try {
|
|
const logData = JSON.parse(line);
|
|
const timestamp = new Date(logData.time).toISOString();
|
|
const level = normalizeLevelName(logData.level);
|
|
addLogEntry(logData.msg || logData.message || line, level, timestamp, line);
|
|
} catch (error) {
|
|
addLogEntry(line, 'INFO', new Date().toISOString(), line);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
} catch (fetchError) {
|
|
// Si le fetch échoue, demander à l'utilisateur de sélectionner le fichier
|
|
}
|
|
}
|
|
|
|
// Demander à l'utilisateur de sélectionner le fichier
|
|
addLogEntry(`Sélectionnez le fichier de log ${filename || ''} à charger`, 'INFO');
|
|
document.body.appendChild(input);
|
|
input.click();
|
|
document.body.removeChild(input);
|
|
|
|
} catch (error) {
|
|
statusElement.textContent = `Erreur: ${error.message}`;
|
|
statusElement.className = 'status disconnected';
|
|
addLogEntry(`Erreur chargement fichier: ${error.message}`, 'ERROR');
|
|
}
|
|
}
|
|
|
|
function normalizeLevelName(level) {
|
|
const levelMap = {10:'TRACE',20:'DEBUG',25:'PROMPT',26:'LLM',30:'INFO',40:'WARN',50:'ERROR',60:'FATAL'};
|
|
if (typeof level === 'number') {
|
|
return levelMap[level] || 'INFO';
|
|
}
|
|
return String(level).toUpperCase();
|
|
}
|
|
|
|
function connect() {
|
|
console.log('🔌 connect() appelé - tentative WebSocket ws://localhost:8082');
|
|
ws = new WebSocket('ws://localhost:8082');
|
|
|
|
ws.onopen = () => {
|
|
console.log('✅ WebSocket connecté !');
|
|
statusElement.textContent = 'Connecté';
|
|
statusElement.className = 'status connected';
|
|
// Reset des tentatives de reconnexion
|
|
reconnectAttempts = 0;
|
|
reconnectDelay = 1000; // Reconnexion ultra rapide
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
console.log('📨 Message WebSocket reçu:', event.data);
|
|
try {
|
|
const logData = JSON.parse(event.data);
|
|
addLogEntry(logData.message, logData.level, logData.timestamp, event.data);
|
|
} catch (error) {
|
|
console.log('❌ Erreur parsing:', error);
|
|
addLogEntry('Erreur parsing log: ' + event.data, 'ERROR');
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
statusElement.textContent = 'Déconnecté';
|
|
statusElement.className = 'status disconnected';
|
|
// Auto-reconnexion immédiate
|
|
scheduleReconnect();
|
|
};
|
|
|
|
ws.onerror = (error) => {
|
|
statusElement.textContent = 'Erreur';
|
|
statusElement.className = 'status disconnected';
|
|
// Auto-reconnexion immédiate
|
|
scheduleReconnect();
|
|
};
|
|
}
|
|
|
|
let showDetailsMode = false;
|
|
|
|
function addLogEntry(message, level = 'INFO', timestamp = null, rawData = null) {
|
|
const logEntry = document.createElement('div');
|
|
logEntry.className = 'log-entry';
|
|
|
|
const time = timestamp ? new Date(timestamp).toLocaleTimeString() : new Date().toLocaleTimeString();
|
|
|
|
// Déterminer si c'est une trace et son type
|
|
let traceClass = '';
|
|
let cleanMessage = message;
|
|
|
|
if (message.includes('▶')) {
|
|
traceClass = 'trace span-start';
|
|
// Nettoyer le message pour garder uniquement l'info utile
|
|
cleanMessage = message.replace('▶ ', '🔵 ');
|
|
} else if (message.includes('✔')) {
|
|
traceClass = 'trace span-end';
|
|
cleanMessage = message.replace('✔ ', '✅ ');
|
|
} else if (message.includes('✖')) {
|
|
traceClass = 'trace span-error';
|
|
cleanMessage = message.replace('✖ ', '❌ ');
|
|
} else if (message.includes('•')) {
|
|
traceClass = 'trace';
|
|
cleanMessage = message.replace('• ', '📝 ');
|
|
} else if (message.includes('Stack trace:') || message.trim().startsWith('at ')) {
|
|
traceClass = 'stack-trace';
|
|
if (message.includes('Stack trace:')) {
|
|
cleanMessage = '🔴 ' + message;
|
|
} else {
|
|
cleanMessage = ' ' + message; // Indentation pour les lignes de stack
|
|
}
|
|
}
|
|
|
|
logEntry.className += ' ' + traceClass;
|
|
|
|
const hasDetails = rawData && rawData !== JSON.stringify({message, level, timestamp});
|
|
const detailsButton = hasDetails ?
|
|
`<button class="details-toggle" onclick="toggleDetails(this)">détails</button>` :
|
|
`<span style="display: inline-block; width: 41px;"></span>`; // Placeholder pour alignement
|
|
|
|
// Détecter si le message est trop long (approximation simple)
|
|
const isMessageTooLong = cleanMessage.length > 80;
|
|
const unwrapButton = isMessageTooLong ?
|
|
`<button class="unwrap-toggle" onclick="toggleUnwrap(this)">unwrap</button>` :
|
|
`<span style="display: inline-block; width: 41px;"></span>`; // Placeholder pour alignement
|
|
|
|
logEntry.innerHTML = `
|
|
${detailsButton}
|
|
${unwrapButton}
|
|
<span class="timestamp">${time}</span>
|
|
<span class="level ${level}">${level}</span>
|
|
${cleanMessage}
|
|
${hasDetails ? `<div class="log-details"><pre>${JSON.stringify(JSON.parse(rawData), null, 2)}</pre></div>` : ''}
|
|
`;
|
|
|
|
// Appliquer le mode détails global si activé
|
|
if (showDetailsMode && hasDetails) {
|
|
logEntry.classList.add('show-details');
|
|
}
|
|
|
|
// Appliquer les filtres de niveau
|
|
applyLevelFilterToEntry(logEntry, level);
|
|
|
|
// Ajouter le click listener pour l'unwrap ligne par ligne
|
|
logEntry.addEventListener('click', (e) => {
|
|
// Ne pas déclencher si on clique sur un bouton
|
|
if (e.target.classList.contains('details-toggle') ||
|
|
e.target.classList.contains('unwrap-toggle')) return;
|
|
toggleLogEntryWrap(logEntry);
|
|
});
|
|
|
|
logsContainer.appendChild(logEntry);
|
|
|
|
// Auto-scroll intelligent : seulement si l'utilisateur est déjà en bas
|
|
if (autoScroll) {
|
|
// Détection plus précise : considérer qu'on est "en bas" si on est à moins de 100px du bas
|
|
const scrollTop = logsContainer.scrollTop;
|
|
const scrollHeight = logsContainer.scrollHeight;
|
|
const clientHeight = logsContainer.clientHeight;
|
|
const isAtBottom = (scrollTop + clientHeight) >= (scrollHeight - 100);
|
|
|
|
if (isAtBottom) {
|
|
// Scroll immédiat vers le bas
|
|
requestAnimationFrame(() => {
|
|
logsContainer.scrollTop = logsContainer.scrollHeight;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function toggleDetails(button) {
|
|
const logEntry = button.parentElement;
|
|
logEntry.classList.toggle('show-details');
|
|
button.textContent = logEntry.classList.contains('show-details') ? 'masquer' : 'détails';
|
|
}
|
|
|
|
function toggleUnwrap(button) {
|
|
const logEntry = button.parentElement;
|
|
|
|
if (logEntry.classList.contains('unwrapped')) {
|
|
// Remettre en mode wrapped
|
|
logEntry.classList.remove('unwrapped');
|
|
logEntry.style.whiteSpace = 'nowrap';
|
|
logEntry.style.overflow = 'hidden';
|
|
logEntry.style.textOverflow = 'ellipsis';
|
|
button.textContent = 'unwrap';
|
|
} else {
|
|
// Passer en mode unwrapped
|
|
logEntry.classList.add('unwrapped');
|
|
logEntry.style.whiteSpace = 'pre-wrap';
|
|
logEntry.style.overflow = 'visible';
|
|
logEntry.style.textOverflow = 'unset';
|
|
button.textContent = 'wrap';
|
|
}
|
|
}
|
|
|
|
function toggleGlobalDetails() {
|
|
showDetailsMode = !showDetailsMode;
|
|
const detailsBtn = document.getElementById('detailsBtn');
|
|
detailsBtn.textContent = `Mode détaillé: ${showDetailsMode ? 'ON' : 'OFF'}`;
|
|
|
|
// Appliquer/retirer le mode détails à toutes les entrées
|
|
const entries = document.querySelectorAll('.log-entry');
|
|
entries.forEach(entry => {
|
|
if (showDetailsMode) {
|
|
entry.classList.add('show-details');
|
|
const toggle = entry.querySelector('.details-toggle');
|
|
if (toggle) toggle.textContent = 'masquer';
|
|
} else {
|
|
entry.classList.remove('show-details');
|
|
const toggle = entry.querySelector('.details-toggle');
|
|
if (toggle) toggle.textContent = 'détails';
|
|
}
|
|
});
|
|
}
|
|
|
|
function clearLogs() {
|
|
logsContainer.innerHTML = '';
|
|
addLogEntry('Logs effacés', 'INFO');
|
|
}
|
|
|
|
function toggleAutoScroll() {
|
|
autoScroll = !autoScroll;
|
|
document.getElementById('autoScrollBtn').textContent = `Auto-scroll: ${autoScroll ? 'ON' : 'OFF'}`;
|
|
}
|
|
|
|
// Variables pour le unwrap ligne par ligne
|
|
let lineUnwrapMode = false;
|
|
|
|
function toggleLineUnwrap() {
|
|
lineUnwrapMode = !lineUnwrapMode;
|
|
document.getElementById('lineUnwrapBtn').textContent = `Unwrap ligne: ${lineUnwrapMode ? 'ON' : 'OFF'}`;
|
|
|
|
if (!lineUnwrapMode) {
|
|
// Désactiver le mode : remettre toutes les lignes en mode compact
|
|
const logEntries = document.querySelectorAll('.log-entry');
|
|
logEntries.forEach(entry => {
|
|
entry.classList.remove('unwrapped');
|
|
});
|
|
}
|
|
}
|
|
|
|
// Fonction pour unwrap/wrap une ligne individuelle
|
|
function toggleLogEntryWrap(logEntry) {
|
|
if (!lineUnwrapMode) return; // Mode désactivé
|
|
|
|
if (logEntry.classList.contains('unwrapped')) {
|
|
// Re-wrapper la ligne
|
|
logEntry.classList.remove('unwrapped');
|
|
} else {
|
|
// Unwrapper la ligne
|
|
logEntry.classList.add('unwrapped');
|
|
}
|
|
}
|
|
|
|
function reconnect() {
|
|
if (ws) {
|
|
ws.close();
|
|
}
|
|
statusElement.textContent = 'Reconnexion...';
|
|
statusElement.className = 'status connecting';
|
|
setTimeout(connect, 1000);
|
|
}
|
|
|
|
// Fonctions de recherche
|
|
function performSearch() {
|
|
const searchInput = document.getElementById('searchInput');
|
|
const searchInfo = document.getElementById('searchInfo');
|
|
const searchPrevBtn = document.getElementById('searchPrevBtn');
|
|
const searchNextBtn = document.getElementById('searchNextBtn');
|
|
|
|
searchTerm = searchInput.value.trim().toLowerCase();
|
|
|
|
// Effacer les recherches précédentes
|
|
clearSearchHighlights();
|
|
searchMatches = [];
|
|
currentMatchIndex = -1;
|
|
|
|
if (searchTerm === '') {
|
|
searchInfo.textContent = '0 résultats';
|
|
searchPrevBtn.disabled = true;
|
|
searchNextBtn.disabled = true;
|
|
return;
|
|
}
|
|
|
|
// Rechercher dans tous les logs visibles
|
|
const logEntries = document.querySelectorAll('.log-entry:not(.hidden-by-filter)');
|
|
logEntries.forEach((entry, index) => {
|
|
const text = entry.textContent.toLowerCase();
|
|
if (text.includes(searchTerm)) {
|
|
searchMatches.push(entry);
|
|
entry.classList.add('search-match');
|
|
|
|
// Highlighter le texte
|
|
highlightTextInElement(entry, searchTerm);
|
|
}
|
|
});
|
|
|
|
// Mettre à jour l'interface
|
|
searchInfo.textContent = `${searchMatches.length} résultat${searchMatches.length > 1 ? 's' : ''}`;
|
|
searchPrevBtn.disabled = searchMatches.length === 0;
|
|
searchNextBtn.disabled = searchMatches.length === 0;
|
|
|
|
// Aller au premier résultat
|
|
if (searchMatches.length > 0) {
|
|
currentMatchIndex = 0;
|
|
scrollToCurrentMatch();
|
|
}
|
|
}
|
|
|
|
function highlightTextInElement(element, term) {
|
|
const walker = document.createTreeWalker(
|
|
element,
|
|
NodeFilter.SHOW_TEXT,
|
|
null,
|
|
false
|
|
);
|
|
|
|
const textNodes = [];
|
|
let node;
|
|
while (node = walker.nextNode()) {
|
|
if (node.textContent.toLowerCase().includes(term)) {
|
|
textNodes.push(node);
|
|
}
|
|
}
|
|
|
|
textNodes.forEach(textNode => {
|
|
const parent = textNode.parentNode;
|
|
const text = textNode.textContent;
|
|
const lowerText = text.toLowerCase();
|
|
const regex = new RegExp(`(${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
|
|
|
if (lowerText.includes(term)) {
|
|
const highlightedHTML = text.replace(regex, '<span class="search-highlight">$1</span>');
|
|
const wrapper = document.createElement('span');
|
|
wrapper.innerHTML = highlightedHTML;
|
|
parent.insertBefore(wrapper, textNode);
|
|
parent.removeChild(textNode);
|
|
}
|
|
});
|
|
}
|
|
|
|
function clearSearchHighlights() {
|
|
const highlights = document.querySelectorAll('.search-highlight');
|
|
highlights.forEach(highlight => {
|
|
const parent = highlight.parentNode;
|
|
parent.replaceChild(document.createTextNode(highlight.textContent), highlight);
|
|
parent.normalize();
|
|
});
|
|
|
|
const searchMatches = document.querySelectorAll('.search-match, .search-current');
|
|
searchMatches.forEach(match => {
|
|
match.classList.remove('search-match', 'search-current');
|
|
});
|
|
}
|
|
|
|
function scrollToCurrentMatch() {
|
|
if (currentMatchIndex >= 0 && currentMatchIndex < searchMatches.length) {
|
|
// Retirer la classe current de l'ancien match
|
|
searchMatches.forEach(match => match.classList.remove('search-current'));
|
|
|
|
// Ajouter la classe current au match actuel
|
|
const currentMatch = searchMatches[currentMatchIndex];
|
|
currentMatch.classList.add('search-current');
|
|
|
|
// Scroller vers l'élément
|
|
currentMatch.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
|
|
// Mettre à jour l'info de recherche
|
|
document.getElementById('searchInfo').textContent =
|
|
`${currentMatchIndex + 1}/${searchMatches.length} résultat${searchMatches.length > 1 ? 's' : ''}`;
|
|
}
|
|
}
|
|
|
|
function searchNext() {
|
|
if (searchMatches.length > 0) {
|
|
currentMatchIndex = (currentMatchIndex + 1) % searchMatches.length;
|
|
scrollToCurrentMatch();
|
|
}
|
|
}
|
|
|
|
function searchPrevious() {
|
|
if (searchMatches.length > 0) {
|
|
currentMatchIndex = currentMatchIndex === 0 ? searchMatches.length - 1 : currentMatchIndex - 1;
|
|
scrollToCurrentMatch();
|
|
}
|
|
}
|
|
|
|
function clearSearch() {
|
|
document.getElementById('searchInput').value = '';
|
|
clearSearchHighlights();
|
|
searchMatches = [];
|
|
currentMatchIndex = -1;
|
|
document.getElementById('searchInfo').textContent = '0 résultats';
|
|
document.getElementById('searchPrevBtn').disabled = true;
|
|
document.getElementById('searchNextBtn').disabled = true;
|
|
}
|
|
|
|
// Event listeners pour la recherche
|
|
document.getElementById('searchInput').addEventListener('input', performSearch);
|
|
document.getElementById('searchInput').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
if (e.shiftKey) {
|
|
searchPrevious();
|
|
} else {
|
|
searchNext();
|
|
}
|
|
} else if (e.key === 'Escape') {
|
|
clearSearch();
|
|
}
|
|
});
|
|
|
|
// Fonctions de filtrage par niveau
|
|
function applyLevelFilterToEntry(entry, level) {
|
|
const normalizedLevel = level.toLowerCase();
|
|
if (!levelFilters[normalizedLevel]) {
|
|
entry.classList.add('hidden-by-filter');
|
|
} else {
|
|
entry.classList.remove('hidden-by-filter');
|
|
}
|
|
}
|
|
|
|
function toggleLevelFilter(level) {
|
|
levelFilters[level] = !levelFilters[level];
|
|
levelFilters['warning'] = levelFilters['warn']; // Synchroniser warn/warning
|
|
|
|
const button = document.getElementById(`${level}Filter`);
|
|
if (levelFilters[level]) {
|
|
button.classList.add('active');
|
|
} else {
|
|
button.classList.remove('active');
|
|
}
|
|
|
|
// Capturer le pourcentage de position AVANT d'appliquer le filtre
|
|
const currentScroll = logsContainer.scrollTop;
|
|
const maxScroll = logsContainer.scrollHeight - logsContainer.clientHeight;
|
|
const currentViewPercentage = maxScroll > 0 ? currentScroll / maxScroll : 0;
|
|
|
|
// Appliquer les filtres à tous les logs
|
|
const entries = document.querySelectorAll('.log-entry');
|
|
entries.forEach(entry => {
|
|
const entryLevel = entry.querySelector('.level').textContent.toLowerCase();
|
|
applyLevelFilterToEntry(entry, entryLevel);
|
|
});
|
|
|
|
// Re-effectuer la recherche si active
|
|
if (searchTerm) {
|
|
performSearch();
|
|
}
|
|
|
|
// Scroll intelligent avec le pourcentage capturé
|
|
smartScrollAfterFilter(currentViewPercentage);
|
|
}
|
|
|
|
function smartScrollAfterFilter(currentViewPercentage) {
|
|
setTimeout(() => {
|
|
const visibleEntries = document.querySelectorAll('.log-entry:not(.hidden-by-filter)');
|
|
if (visibleEntries.length === 0) return;
|
|
|
|
// Si on a un match de recherche actuel, privilégier celui-ci
|
|
if (currentMatchIndex >= 0 && currentMatchIndex < searchMatches.length) {
|
|
const currentSearchMatch = searchMatches[currentMatchIndex];
|
|
if (!currentSearchMatch.classList.contains('hidden-by-filter')) {
|
|
currentSearchMatch.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Appliquer le même pourcentage aux nouvelles entrées visibles
|
|
// Attendre que le DOM se mette à jour après l'application des filtres
|
|
setTimeout(() => {
|
|
const newMaxScroll = logsContainer.scrollHeight - logsContainer.clientHeight;
|
|
const targetScroll = newMaxScroll * currentViewPercentage;
|
|
|
|
logsContainer.scrollTo({
|
|
top: Math.max(0, Math.min(targetScroll, newMaxScroll)),
|
|
behavior: 'smooth'
|
|
});
|
|
}, 50);
|
|
}, 100);
|
|
}
|
|
|
|
// Raccourci Ctrl+F
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.ctrlKey && e.key === 'f') {
|
|
e.preventDefault();
|
|
document.getElementById('searchInput').focus();
|
|
}
|
|
});
|
|
|
|
// Connexion initiale SEULEMENT si pas en mode fichier
|
|
// (connect() est déjà appelé dans la logique if/else plus haut)
|
|
|
|
// Auto-reconnexion intelligente
|
|
let reconnectDelay = 1000; // 1 seconde
|
|
let reconnectAttempts = 0;
|
|
let maxReconnectAttempts = 50; // Limite raisonnable
|
|
|
|
function scheduleReconnect() {
|
|
if (reconnectAttempts >= maxReconnectAttempts) {
|
|
addLogEntry('Nombre max de tentatives de reconnexion atteint', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
setTimeout(() => {
|
|
if (!ws || ws.readyState === WebSocket.CLOSED) {
|
|
reconnectAttempts++;
|
|
statusElement.textContent = `Reconnexion... (${reconnectAttempts}/${maxReconnectAttempts})`;
|
|
statusElement.className = 'status connecting';
|
|
connect();
|
|
}
|
|
}, reconnectDelay);
|
|
}
|
|
|
|
// Gestion intelligente de l'auto-scroll basée sur le comportement utilisateur
|
|
let userScrolledAway = false;
|
|
let scrollTimeout;
|
|
|
|
logsContainer.addEventListener('scroll', () => {
|
|
if (!autoScroll) return;
|
|
|
|
clearTimeout(scrollTimeout);
|
|
|
|
const scrollTop = logsContainer.scrollTop;
|
|
const scrollHeight = logsContainer.scrollHeight;
|
|
const clientHeight = logsContainer.clientHeight;
|
|
const isAtBottom = (scrollTop + clientHeight) >= (scrollHeight - 100);
|
|
|
|
if (isAtBottom) {
|
|
// L'utilisateur est revenu en bas, réactiver l'auto-scroll
|
|
if (userScrolledAway) {
|
|
userScrolledAway = false;
|
|
console.log('🔄 Auto-scroll réactivé - utilisateur revenu en bas');
|
|
}
|
|
} else {
|
|
// L'utilisateur a scrollé vers le haut, marquer qu'il s'est éloigné du bas
|
|
userScrolledAway = true;
|
|
}
|
|
|
|
// Debounce pour éviter trop d'événements
|
|
scrollTimeout = setTimeout(() => {
|
|
// Logique supplémentaire si nécessaire
|
|
}, 150);
|
|
});
|
|
|
|
// Améliorer addLogEntry pour respecter userScrolledAway
|
|
const originalAddLogEntry = addLogEntry;
|
|
function enhancedAddLogEntry(message, level = 'INFO', timestamp = null, rawData = null) {
|
|
originalAddLogEntry(message, level, timestamp, rawData);
|
|
|
|
// Override : si l'utilisateur n'a pas scrollé manuellement ET que l'auto-scroll est ON,
|
|
// forcer le scroll vers le bas
|
|
if (autoScroll && !userScrolledAway) {
|
|
requestAnimationFrame(() => {
|
|
logsContainer.scrollTop = logsContainer.scrollHeight;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Remplacer la fonction globale
|
|
addLogEntry = enhancedAddLogEntry;
|
|
</script>
|
|
</body>
|
|
</html> |