From fa0206a3f93f5f45ec02b441154a121f6fc528aa Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 10 Oct 2025 23:59:30 +0200 Subject: [PATCH] =?UTF-8?q?Am=C3=A9lioration=20des=20log=20dans=20le=20log?= =?UTF-8?q?view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pmoapp/webapp/src/components/LogView.vue | 303 ++++++++++++++++++++--- 1 file changed, 262 insertions(+), 41 deletions(-) diff --git a/pmoapp/webapp/src/components/LogView.vue b/pmoapp/webapp/src/components/LogView.vue index 9442f921..e4bd4ae0 100644 --- a/pmoapp/webapp/src/components/LogView.vue +++ b/pmoapp/webapp/src/components/LogView.vue @@ -26,6 +26,20 @@ + + + @@ -42,13 +56,14 @@
-
- - {{ log.truncatedMessage }} - -
-
-
+ +
@@ -67,7 +82,12 @@ {{ isConnected ? '🟢 Connected' : '🔴 Disconnected' }} Server level: {{ serverLogLevel }} - {{ filteredLogs.length }} logs + + {{ filteredLogs.length }} log{{ filteredLogs.length !== 1 ? 's' : '' }} + + ({{ filteredLogs.length }} / {{ logs.length }} matching "{{ searchQuery }}") + + @@ -89,6 +109,7 @@ const isConnected = ref(false) const isLoadingHistory = ref(true) const levelFilter = ref('ALL') const serverLogLevel = ref('TRACE') +const searchQuery = ref('') const logContainer = ref(null) let eventSource = null let historyLoaded = false @@ -103,14 +124,29 @@ const levelOrder = { 'TRACE': 4 } -// Pré-calculer filteredLogs de manière optimisée +// Pré-calculer filteredLogs de manière optimisée avec recherche const filteredLogs = computed(() => { - if (levelFilter.value === 'ALL') { - return logs.value + let filtered = logs.value + + // Filtre par niveau + if (levelFilter.value !== 'ALL') { + const filter = levelFilter.value + filtered = filtered.filter(log => log.level === filter) } - // Utiliser la référence directe pour éviter des copies inutiles - const filter = levelFilter.value - return logs.value.filter(log => log.level === filter) + + // Filtre par recherche + if (searchQuery.value.trim()) { + const query = searchQuery.value.toLowerCase() + filtered = filtered.filter(log => { + return ( + log.message.toLowerCase().includes(query) || + log.level.toLowerCase().includes(query) || + log.target.toLowerCase().includes(query) + ) + }) + } + + return filtered }) // Fonction pour mettre à jour le niveau de log côté serveur @@ -173,51 +209,115 @@ function preprocessLog(message) { : message.substring(0, 200).trim()) : null - // ÉTAPE 3: Pré-processing pour détecter et protéger le XML + // ÉTAPE 3: Pré-processing pour détecter et protéger le XML, images et audio let processedText = message - // Détecter si le message contient du XML - const hasXml = /<\?xml|<(scpd|root|service|device|actionList|stateVariable)[>\s]/i.test(message) + // ÉTAPE 3a: Détecter et marquer les URLs audio AVANT tout traitement markdown + // On utilise des marqueurs UUID pour éviter les conflits + const audioMarkers = new Map() + const audioUrlPattern = /(https?:\/\/[^\s"<>]+\.(?:mp3|wav|ogg|m4a|flac|aac|opus|weba)(?:\?[^\s"<>]*)?)/gi + processedText = processedText.replace(audioUrlPattern, (match) => { + const markerId = `AUDIO_MARKER_${Math.random().toString(36).substring(2, 11)}` + audioMarkers.set(markerId, match) + return markerId + }) + + // ÉTAPE 3b: Détecter et transformer les liens d'images + const imageUrlPattern = /(https?:\/\/[^\s"<>]+\.(?:png|jpg|jpeg|gif|webp|svg)(?:\?[^\s"<>]*)?)/gi + processedText = processedText.replace(imageUrlPattern, (match) => { + return `\n![Image](${match})\n` + }) + + // ÉTAPE 3c: Détecter si le message contient du XML + const hasXml = /<\?xml|<(scpd|root|service|device|actionList|stateVariable)[>\s]/i.test(processedText) if (hasXml) { - const xmlStartMatch = message.match(/<\?xml[\s\S]*$/) + const xmlStartMatch = processedText.match(/<\?xml[\s\S]*$/) if (xmlStartMatch) { const xmlContent = xmlStartMatch[0] - const beforeXml = message.substring(0, message.indexOf(xmlContent)) + const beforeXml = processedText.substring(0, processedText.indexOf(xmlContent)) processedText = beforeXml + '\n```xml\n' + xmlContent + '\n```\n' } else { - const xmlMatch = message.match(/<([a-zA-Z][a-zA-Z0-9:-]*)[>\s][\s\S]*/) + const xmlMatch = processedText.match(/<([a-zA-Z][a-zA-Z0-9:-]*)[>\s][\s\S]*/) if (xmlMatch) { const xmlContent = xmlMatch[0] - const beforeXml = message.substring(0, message.indexOf(xmlContent)) + const beforeXml = processedText.substring(0, processedText.indexOf(xmlContent)) processedText = beforeXml + '\n```xml\n' + xmlContent + '\n```\n' } } } - // ÉTAPE 4: Détecter et transformer les liens d'images - const imageUrlPattern = /(https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|svg)(?:\?[^\s]*)?)/gi - processedText = processedText.replace(imageUrlPattern, (match) => { - return `\n![Image](${match})\n` - }) - // ÉTAPE 5: Convertir markdown en HTML const rawHtml = marked.parse(processedText, { async: false }) // ÉTAPE 6: Nettoyer pour la sécurité - const renderedHtml = DOMPurify.sanitize(rawHtml, { + let renderedHtml = DOMPurify.sanitize(rawHtml, { ALLOWED_TAGS: ['strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span', 'img'], ALLOWED_ATTR: ['href', 'target', 'class', 'src', 'alt', 'title'] }) + // ÉTAPE 6b: Remplacer les marqueurs audio par de vrais lecteurs HTML5 + for (const [markerId, audioUrl] of audioMarkers.entries()) { + const audioPlayer = `
+ + +
` + + renderedHtml = renderedHtml.replace(new RegExp(markerId, 'g'), audioPlayer) + } + + const finalHtml = renderedHtml + + // ÉTAPE 7: Générer le HTML du message tronqué si nécessaire + let truncatedHtml = null + if (isTooLong && truncatedMessage) { + const truncatedRaw = marked.parse(truncatedMessage, { async: false }) + let cleanTruncated = DOMPurify.sanitize(truncatedRaw, { + ALLOWED_TAGS: ['strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span', 'img'], + ALLOWED_ATTR: ['href', 'target', 'class', 'src', 'alt', 'title'] + }) + + // Remplacer les marqueurs audio dans le message tronqué aussi + for (const [markerId, audioUrl] of audioMarkers.entries()) { + const audioPlayer = `
+ + +
` + + cleanTruncated = cleanTruncated.replace(new RegExp(markerId, 'g'), audioPlayer) + } + + truncatedHtml = cleanTruncated + } + return { isTooLong, truncatedMessage, - renderedHtml + truncatedHtml, + renderedHtml: finalHtml } } +function highlightSearchTerm(html) { + if (!searchQuery.value.trim() || !html) { + return html + } + + const query = searchQuery.value.trim() + // Créer une regex insensible à la casse pour trouver le terme + // Utiliser un lookahead négatif pour éviter de matcher dans les balises HTML + const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})(?![^<]*>)`, 'gi') + + return html.replace(regex, '$1') +} + function toggleAutoScroll() { autoScroll.value = !autoScroll.value if (autoScroll.value) { @@ -269,7 +369,9 @@ function connectSSE() { const processed = preprocessLog(logEntry.message) logEntry.isTooLong = processed.isTooLong logEntry.truncatedMessage = processed.truncatedMessage + logEntry.truncatedHtml = processed.truncatedHtml logEntry.renderedHtml = processed.renderedHtml + logEntry.expanded = false // État de dépliage initial logs.value.push(logEntry) @@ -471,6 +573,69 @@ button.active { } } +.search-box { + position: relative; + display: flex; + align-items: center; + flex: 1; + min-width: 200px; + max-width: 400px; +} + +.search-input { + flex: 1; + padding: 0.5rem 2rem 0.5rem 0.75rem; + background: #3c3c3c; + color: #d4d4d4; + border: 1px solid #555; + border-radius: 4px; + font-size: 0.9rem; + font-family: 'Consolas', 'Monaco', monospace; + outline: none; + transition: all 0.2s; +} + +.search-input:focus { + border-color: #569cd6; + background: #2d2d30; + box-shadow: 0 0 0 2px rgba(86, 156, 214, 0.2); +} + +.search-input::placeholder { + color: #858585; + font-style: italic; +} + +.clear-search { + position: absolute; + right: 0.25rem; + background: transparent; + border: none; + color: #858585; + cursor: pointer; + padding: 0.25rem 0.5rem; + font-size: 0.9rem; + border-radius: 3px; + transition: all 0.2s; +} + +.clear-search:hover { + background: #505050; + color: #d4d4d4; +} + +@media (max-width: 768px) { + .search-box { + width: 100%; + max-width: none; + } + + .search-input { + font-size: 0.8rem; + padding: 0.4rem 2rem 0.4rem 0.6rem; + } +} + .log-container { flex: 1; overflow-y: auto; @@ -582,9 +747,10 @@ button.active { color: #569cd6; list-style: none; user-select: none; - display: flex; - align-items: baseline; - gap: 0.5rem; + display: inline-block; + margin: 0; + padding: 0; + min-height: 1em; } .log-summary::-webkit-details-marker { @@ -596,31 +762,28 @@ button.active { } .log-summary::before { - content: '▶'; + content: '▶ Afficher plus'; display: inline-block; - width: 1em; transition: transform 0.2s; color: #569cd6; - font-size: 0.8em; + font-size: 0.85em; + font-style: italic; } .log-details[open] .log-summary::before { - transform: rotate(90deg); -} - -.log-summary:hover { - color: #6fa8dc; + content: '▼ Afficher moins'; } .log-summary:hover::before { color: #6fa8dc; } -.truncated-text { +.truncated-preview { color: #d4d4d4; font-family: 'Consolas', 'Monaco', monospace; white-space: pre-wrap; word-break: break-word; + margin-bottom: 0.25rem; } .full-message { @@ -678,6 +841,49 @@ button.active { display: block; } +/* Style pour les lecteurs audio */ +.markdown-content :deep(.audio-player-wrapper) { + margin: 0.75rem 0; + padding: 0.75rem; + background: #2d2d30; + border: 1px solid #3e3e42; + border-radius: 6px; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.markdown-content :deep(.log-audio-player) { + width: 100%; + max-width: 500px; + height: 40px; + border-radius: 4px; + background: #1e1e1e; + outline: none; +} + +.markdown-content :deep(.log-audio-player:focus) { + outline: 2px solid #569cd6; + outline-offset: 2px; +} + +.markdown-content :deep(.audio-url) { + font-size: 0.85em; + color: #858585; + font-style: italic; + word-break: break-all; +} + +.markdown-content :deep(.audio-url a) { + color: #569cd6; + text-decoration: none; +} + +.markdown-content :deep(.audio-url a:hover) { + color: #6fa8dc; + text-decoration: underline; +} + /* Scrollbar pour les blocs de code longs */ .markdown-content :deep(pre)::-webkit-scrollbar { width: 8px; @@ -827,6 +1033,21 @@ button.active { color: #858585; } +.search-results { + color: #569cd6; + font-weight: bold; + font-style: italic; +} + +/* Surlignage des termes recherchés */ +.markdown-content :deep(mark.search-highlight) { + background: #ffd700; + color: #1e1e1e; + padding: 0.1rem 0.2rem; + border-radius: 2px; + font-weight: bold; +} + /* Scrollbar styling */ .log-container::-webkit-scrollbar { width: 12px;