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\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\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;