2025-09-23 20:54:50 +02:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="log-viewer">
|
|
|
|
|
|
<div class="header">
|
|
|
|
|
|
<h2>📋 System Logs</h2>
|
|
|
|
|
|
<div class="controls">
|
|
|
|
|
|
<button @click="toggleAutoScroll" :class="{ active: autoScroll }">
|
|
|
|
|
|
{{ autoScroll ? '📌 Auto-scroll ON' : '📌 Auto-scroll OFF' }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button @click="clearLogs">🗑️ Clear</button>
|
2025-10-10 06:58:48 +02:00
|
|
|
|
|
2025-10-18 22:14:41 +02:00
|
|
|
|
<!-- Sélection du niveau de log -->
|
|
|
|
|
|
<select v-model="serverLogLevel" @change="updateServerLogLevel" class="filter log-level">
|
2025-10-10 06:58:48 +02:00
|
|
|
|
<option value="ERROR">🔴 ERROR only</option>
|
|
|
|
|
|
<option value="WARN">🟡 WARN+</option>
|
|
|
|
|
|
<option value="INFO">🟢 INFO+</option>
|
|
|
|
|
|
<option value="DEBUG">🔵 DEBUG+</option>
|
|
|
|
|
|
<option value="TRACE">⚪ TRACE (all)</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
|
2025-10-10 23:59:30 +02:00
|
|
|
|
<!-- Champ de recherche -->
|
|
|
|
|
|
<div class="search-box">
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model="searchQuery"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="🔍 Search logs..."
|
|
|
|
|
|
class="search-input"
|
|
|
|
|
|
@keyup.escape="searchQuery = ''"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button v-if="searchQuery" @click="searchQuery = ''" class="clear-search" title="Clear search">
|
|
|
|
|
|
✖
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2025-09-23 20:54:50 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="log-container" ref="logContainer">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="(log, index) in filteredLogs"
|
|
|
|
|
|
:key="index"
|
|
|
|
|
|
:class="['log-entry', `level-${log.level.toLowerCase()}`, { 'is-history': log.isHistory }]"
|
|
|
|
|
|
>
|
2025-10-10 06:58:48 +02:00
|
|
|
|
<div class="log-header">
|
|
|
|
|
|
<span class="timestamp">{{ formatTimestamp(log.timestamp) }}</span>
|
|
|
|
|
|
<span class="level">{{ log.level }}</span>
|
|
|
|
|
|
<span class="target">{{ log.target }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="log-content">
|
|
|
|
|
|
<div class="message markdown-content">
|
2025-10-10 23:59:30 +02:00
|
|
|
|
<template v-if="log.isTooLong">
|
|
|
|
|
|
<div v-show="!log.expanded" class="truncated-preview" v-html="highlightSearchTerm(log.truncatedHtml)"></div>
|
|
|
|
|
|
<details class="log-details" @toggle="log.expanded = $event.target.open">
|
|
|
|
|
|
<summary class="log-summary"></summary>
|
|
|
|
|
|
<div class="full-message" v-html="highlightSearchTerm(log.renderedHtml)"></div>
|
|
|
|
|
|
</details>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<div v-else v-html="highlightSearchTerm(log.renderedHtml)"></div>
|
2025-10-10 06:58:48 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-09-23 20:54:50 +02:00
|
|
|
|
</div>
|
2025-10-10 06:58:48 +02:00
|
|
|
|
|
2025-09-23 20:54:50 +02:00
|
|
|
|
<div v-if="isLoadingHistory" class="loading-state">
|
|
|
|
|
|
⏳ Loading history...
|
|
|
|
|
|
</div>
|
2025-10-10 06:58:48 +02:00
|
|
|
|
|
2025-09-23 20:54:50 +02:00
|
|
|
|
<div v-else-if="filteredLogs.length === 0" class="empty-state">
|
|
|
|
|
|
{{ isConnected ? 'Waiting for logs...' : 'Connecting to log stream...' }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="footer">
|
|
|
|
|
|
<span :class="['status', { connected: isConnected }]">
|
|
|
|
|
|
{{ isConnected ? '🟢 Connected' : '🔴 Disconnected' }}
|
|
|
|
|
|
</span>
|
2025-10-18 22:14:41 +02:00
|
|
|
|
<span class="server-info">Level: {{ serverLogLevel }}</span>
|
2025-10-10 23:59:30 +02:00
|
|
|
|
<span class="count">
|
|
|
|
|
|
{{ filteredLogs.length }} log{{ filteredLogs.length !== 1 ? 's' : '' }}
|
|
|
|
|
|
<span v-if="searchQuery.trim()" class="search-results">
|
2025-10-18 22:14:41 +02:00
|
|
|
|
({{ filteredLogs.length }} / {{ logs.length }} matching)
|
2025-10-10 23:59:30 +02:00
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
2025-09-23 20:54:50 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
|
|
|
|
|
import { marked } from 'marked'
|
|
|
|
|
|
import DOMPurify from 'dompurify'
|
|
|
|
|
|
|
|
|
|
|
|
// Configurer marked pour un rendu inline simple
|
|
|
|
|
|
marked.setOptions({
|
|
|
|
|
|
breaks: true,
|
|
|
|
|
|
gfm: true,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const logs = ref([])
|
|
|
|
|
|
const autoScroll = ref(true)
|
|
|
|
|
|
const isConnected = ref(false)
|
|
|
|
|
|
const isLoadingHistory = ref(true)
|
2025-10-10 06:58:48 +02:00
|
|
|
|
const serverLogLevel = ref('TRACE')
|
2025-10-10 23:59:30 +02:00
|
|
|
|
const searchQuery = ref('')
|
2025-09-23 20:54:50 +02:00
|
|
|
|
const logContainer = ref(null)
|
|
|
|
|
|
let eventSource = null
|
|
|
|
|
|
let historyLoaded = false
|
|
|
|
|
|
const seenLogIds = new Set() // Pour détecter les duplicatas
|
|
|
|
|
|
|
2025-10-18 22:14:41 +02:00
|
|
|
|
// Filtrer les logs uniquement par recherche
|
2025-09-23 20:54:50 +02:00
|
|
|
|
const filteredLogs = computed(() => {
|
2025-10-10 23:59:30 +02:00
|
|
|
|
let filtered = logs.value
|
|
|
|
|
|
|
|
|
|
|
|
// 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
|
2025-09-23 20:54:50 +02:00
|
|
|
|
})
|
|
|
|
|
|
|
2025-10-10 06:58:48 +02:00
|
|
|
|
// Fonction pour mettre à jour le niveau de log côté serveur
|
|
|
|
|
|
async function updateServerLogLevel() {
|
|
|
|
|
|
try {
|
2025-10-20 16:23:50 +02:00
|
|
|
|
const response = await fetch('/api/logs/log_setup', {
|
2025-10-10 06:58:48 +02:00
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
level: serverLogLevel.value
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const data = await response.json()
|
|
|
|
|
|
console.log('Log level updated:', data.current_level)
|
2025-10-18 22:14:41 +02:00
|
|
|
|
|
|
|
|
|
|
// Fermer la connexion SSE actuelle
|
|
|
|
|
|
if (eventSource) {
|
|
|
|
|
|
eventSource.close()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Vider les logs actuels et réinitialiser
|
|
|
|
|
|
logs.value = []
|
|
|
|
|
|
seenLogIds.clear()
|
|
|
|
|
|
historyLoaded = false
|
|
|
|
|
|
isLoadingHistory.value = true
|
|
|
|
|
|
|
|
|
|
|
|
// Reconnecter au SSE avec le nouveau niveau
|
|
|
|
|
|
connectSSE()
|
2025-10-10 06:58:48 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
console.error('Failed to update log level')
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error updating log level:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Charger le niveau de log actuel au démarrage
|
|
|
|
|
|
async function loadServerLogLevel() {
|
|
|
|
|
|
try {
|
2025-10-20 16:23:50 +02:00
|
|
|
|
const response = await fetch('/api/logs/log_setup')
|
2025-10-10 06:58:48 +02:00
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const data = await response.json()
|
|
|
|
|
|
serverLogLevel.value = data.current_level
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading log level:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-23 20:54:50 +02:00
|
|
|
|
function formatTimestamp(timestamp) {
|
|
|
|
|
|
const date = new Date(timestamp.secs_since_epoch * 1000)
|
|
|
|
|
|
return date.toLocaleTimeString('fr-FR', {
|
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
|
second: '2-digit',
|
|
|
|
|
|
fractionalSecondDigits: 3
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 17:01:15 +02:00
|
|
|
|
// Pré-traiter un log : calculer HTML, troncature, etc. UNE SEULE FOIS
|
|
|
|
|
|
function preprocessLog(message) {
|
|
|
|
|
|
// ÉTAPE 1: Déterminer si trop long
|
2025-10-10 06:58:48 +02:00
|
|
|
|
const firstLineEnd = message.indexOf('\n')
|
2025-10-10 17:01:15 +02:00
|
|
|
|
const isTooLong = firstLineEnd !== -1 || message.length > 200
|
2025-10-10 06:58:48 +02:00
|
|
|
|
|
2025-10-10 17:01:15 +02:00
|
|
|
|
// ÉTAPE 2: Calculer le message tronqué si nécessaire
|
|
|
|
|
|
const truncatedMessage = isTooLong
|
|
|
|
|
|
? (firstLineEnd !== -1
|
|
|
|
|
|
? message.substring(0, firstLineEnd).trim()
|
|
|
|
|
|
: message.substring(0, 200).trim())
|
|
|
|
|
|
: null
|
2025-10-10 06:58:48 +02:00
|
|
|
|
|
2025-10-10 23:59:30 +02:00
|
|
|
|
// ÉTAPE 3: Pré-processing pour détecter et protéger le XML, images et audio
|
2025-10-10 17:01:15 +02:00
|
|
|
|
let processedText = message
|
2025-10-06 07:31:03 +02:00
|
|
|
|
|
2025-10-10 23:59:30 +02:00
|
|
|
|
// É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)
|
2025-10-06 07:31:03 +02:00
|
|
|
|
|
|
|
|
|
|
if (hasXml) {
|
2025-10-10 23:59:30 +02:00
|
|
|
|
const xmlStartMatch = processedText.match(/<\?xml[\s\S]*$/)
|
2025-10-06 07:31:03 +02:00
|
|
|
|
|
|
|
|
|
|
if (xmlStartMatch) {
|
|
|
|
|
|
const xmlContent = xmlStartMatch[0]
|
2025-10-10 23:59:30 +02:00
|
|
|
|
const beforeXml = processedText.substring(0, processedText.indexOf(xmlContent))
|
2025-10-06 07:31:03 +02:00
|
|
|
|
processedText = beforeXml + '\n```xml\n' + xmlContent + '\n```\n'
|
|
|
|
|
|
} else {
|
2025-10-10 23:59:30 +02:00
|
|
|
|
const xmlMatch = processedText.match(/<([a-zA-Z][a-zA-Z0-9:-]*)[>\s][\s\S]*/)
|
2025-10-06 07:31:03 +02:00
|
|
|
|
if (xmlMatch) {
|
|
|
|
|
|
const xmlContent = xmlMatch[0]
|
2025-10-10 23:59:30 +02:00
|
|
|
|
const beforeXml = processedText.substring(0, processedText.indexOf(xmlContent))
|
2025-10-06 07:31:03 +02:00
|
|
|
|
processedText = beforeXml + '\n```xml\n' + xmlContent + '\n```\n'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 17:01:15 +02:00
|
|
|
|
// ÉTAPE 5: Convertir markdown en HTML
|
2025-10-06 07:31:03 +02:00
|
|
|
|
const rawHtml = marked.parse(processedText, { async: false })
|
|
|
|
|
|
|
2025-10-10 17:01:15 +02:00
|
|
|
|
// ÉTAPE 6: Nettoyer pour la sécurité
|
2025-10-10 23:59:30 +02:00
|
|
|
|
let renderedHtml = DOMPurify.sanitize(rawHtml, {
|
2025-10-10 06:58:48 +02:00
|
|
|
|
ALLOWED_TAGS: ['strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span', 'img'],
|
|
|
|
|
|
ALLOWED_ATTR: ['href', 'target', 'class', 'src', 'alt', 'title']
|
2025-09-23 20:54:50 +02:00
|
|
|
|
})
|
2025-10-10 17:01:15 +02:00
|
|
|
|
|
2025-10-10 23:59:30 +02:00
|
|
|
|
// ÉTAPE 6b: Remplacer les marqueurs audio par de vrais lecteurs HTML5
|
|
|
|
|
|
for (const [markerId, audioUrl] of audioMarkers.entries()) {
|
|
|
|
|
|
const audioPlayer = `<div class="audio-player-wrapper">
|
|
|
|
|
|
<audio controls preload="metadata" class="log-audio-player">
|
|
|
|
|
|
<source src="${audioUrl}" type="audio/${audioUrl.split('.').pop().split('?')[0]}">
|
|
|
|
|
|
Your browser does not support the audio element.
|
|
|
|
|
|
</audio>
|
|
|
|
|
|
<div class="audio-url"><a href="${audioUrl}" target="_blank" rel="noopener">${audioUrl}</a></div>
|
|
|
|
|
|
</div>`
|
|
|
|
|
|
|
|
|
|
|
|
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 = `<div class="audio-player-wrapper">
|
|
|
|
|
|
<audio controls preload="metadata" class="log-audio-player">
|
|
|
|
|
|
<source src="${audioUrl}" type="audio/${audioUrl.split('.').pop().split('?')[0]}">
|
|
|
|
|
|
Your browser does not support the audio element.
|
|
|
|
|
|
</audio>
|
|
|
|
|
|
<div class="audio-url"><a href="${audioUrl}" target="_blank" rel="noopener">${audioUrl}</a></div>
|
|
|
|
|
|
</div>`
|
|
|
|
|
|
|
|
|
|
|
|
cleanTruncated = cleanTruncated.replace(new RegExp(markerId, 'g'), audioPlayer)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
truncatedHtml = cleanTruncated
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 17:01:15 +02:00
|
|
|
|
return {
|
|
|
|
|
|
isTooLong,
|
|
|
|
|
|
truncatedMessage,
|
2025-10-10 23:59:30 +02:00
|
|
|
|
truncatedHtml,
|
|
|
|
|
|
renderedHtml: finalHtml
|
2025-10-10 17:01:15 +02:00
|
|
|
|
}
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 23:59:30 +02:00
|
|
|
|
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, '<mark class="search-highlight">$1</mark>')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-23 20:54:50 +02:00
|
|
|
|
function toggleAutoScroll() {
|
|
|
|
|
|
autoScroll.value = !autoScroll.value
|
|
|
|
|
|
if (autoScroll.value) {
|
|
|
|
|
|
scrollToBottom()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clearLogs() {
|
|
|
|
|
|
logs.value = []
|
|
|
|
|
|
seenLogIds.clear()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function scrollToBottom() {
|
|
|
|
|
|
if (!logContainer.value || !autoScroll.value) return
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
logContainer.value.scrollTop = logContainer.value.scrollHeight
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function connectSSE() {
|
|
|
|
|
|
// Ajuste l'URL selon ton setup
|
|
|
|
|
|
const baseUrl = window.location.origin
|
|
|
|
|
|
eventSource = new EventSource(`${baseUrl}/log-sse`)
|
|
|
|
|
|
|
|
|
|
|
|
eventSource.onopen = () => {
|
|
|
|
|
|
isConnected.value = true
|
|
|
|
|
|
console.log('SSE connection opened')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
eventSource.onmessage = (event) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const logEntry = JSON.parse(event.data)
|
2025-10-10 06:58:48 +02:00
|
|
|
|
|
2025-09-23 20:54:50 +02:00
|
|
|
|
// Créer un ID unique basé sur timestamp + message + target
|
|
|
|
|
|
const logId = `${logEntry.timestamp.secs_since_epoch}-${logEntry.timestamp.nanos_since_epoch}-${logEntry.message}-${logEntry.target}`
|
2025-10-10 06:58:48 +02:00
|
|
|
|
|
2025-09-23 20:54:50 +02:00
|
|
|
|
// Ignorer les duplicatas
|
|
|
|
|
|
if (seenLogIds.has(logId)) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
seenLogIds.add(logId)
|
2025-10-10 06:58:48 +02:00
|
|
|
|
|
2025-09-23 20:54:50 +02:00
|
|
|
|
// Marquer les logs historiques
|
|
|
|
|
|
if (!historyLoaded) {
|
|
|
|
|
|
logEntry.isHistory = true
|
|
|
|
|
|
}
|
2025-10-10 06:58:48 +02:00
|
|
|
|
|
2025-10-10 17:01:15 +02:00
|
|
|
|
// PRÉ-TRAITER le log UNE SEULE FOIS à la réception
|
|
|
|
|
|
const processed = preprocessLog(logEntry.message)
|
|
|
|
|
|
logEntry.isTooLong = processed.isTooLong
|
|
|
|
|
|
logEntry.truncatedMessage = processed.truncatedMessage
|
2025-10-10 23:59:30 +02:00
|
|
|
|
logEntry.truncatedHtml = processed.truncatedHtml
|
2025-10-10 17:01:15 +02:00
|
|
|
|
logEntry.renderedHtml = processed.renderedHtml
|
2025-10-10 23:59:30 +02:00
|
|
|
|
logEntry.expanded = false // État de dépliage initial
|
2025-10-10 17:01:15 +02:00
|
|
|
|
|
2025-09-23 20:54:50 +02:00
|
|
|
|
logs.value.push(logEntry)
|
2025-10-10 06:58:48 +02:00
|
|
|
|
|
2025-09-23 20:54:50 +02:00
|
|
|
|
// Limiter à 1000 logs en mémoire
|
|
|
|
|
|
if (logs.value.length > 1000) {
|
|
|
|
|
|
const removed = logs.value.shift()
|
|
|
|
|
|
// Nettoyer aussi le Set pour éviter qu'il grandisse indéfiniment
|
|
|
|
|
|
const removedId = `${removed.timestamp.secs_since_epoch}-${removed.timestamp.nanos_since_epoch}-${removed.message}-${removed.target}`
|
|
|
|
|
|
seenLogIds.delete(removedId)
|
|
|
|
|
|
}
|
2025-10-10 06:58:48 +02:00
|
|
|
|
|
2025-09-23 20:54:50 +02:00
|
|
|
|
scrollToBottom()
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to parse log entry:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
eventSource.onerror = () => {
|
|
|
|
|
|
isConnected.value = false
|
|
|
|
|
|
isLoadingHistory.value = false
|
|
|
|
|
|
console.error('SSE connection error')
|
2025-10-10 06:58:48 +02:00
|
|
|
|
|
2025-09-23 20:54:50 +02:00
|
|
|
|
// Reconnexion automatique après 3 secondes
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
if (eventSource.readyState === EventSource.CLOSED) {
|
|
|
|
|
|
historyLoaded = false
|
|
|
|
|
|
connectSSE()
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 3000)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Détecter la fin du chargement de l'historique
|
|
|
|
|
|
// (on considère qu'après 500ms sans log, l'historique est chargé)
|
|
|
|
|
|
let historyTimeout
|
|
|
|
|
|
const originalOnMessage = eventSource.onmessage
|
|
|
|
|
|
eventSource.onmessage = (event) => {
|
|
|
|
|
|
clearTimeout(historyTimeout)
|
|
|
|
|
|
originalOnMessage(event)
|
2025-10-10 06:58:48 +02:00
|
|
|
|
|
2025-09-23 20:54:50 +02:00
|
|
|
|
if (!historyLoaded) {
|
|
|
|
|
|
historyTimeout = setTimeout(() => {
|
|
|
|
|
|
historyLoaded = true
|
|
|
|
|
|
isLoadingHistory.value = false
|
|
|
|
|
|
console.log('History loaded, now streaming live logs')
|
|
|
|
|
|
}, 500)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
2025-10-10 06:58:48 +02:00
|
|
|
|
loadServerLogLevel()
|
2025-09-23 20:54:50 +02:00
|
|
|
|
connectSSE()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
if (eventSource) {
|
|
|
|
|
|
eventSource.close()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// Désactiver auto-scroll si l'utilisateur scroll manuellement
|
2025-10-10 17:01:15 +02:00
|
|
|
|
let scrollHandler = null
|
|
|
|
|
|
watch(logContainer, (container, oldContainer) => {
|
|
|
|
|
|
// Nettoyer l'ancien listener si existant
|
|
|
|
|
|
if (oldContainer && scrollHandler) {
|
|
|
|
|
|
oldContainer.removeEventListener('scroll', scrollHandler)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-23 20:54:50 +02:00
|
|
|
|
if (!container) return
|
2025-10-10 06:58:48 +02:00
|
|
|
|
|
2025-10-10 17:01:15 +02:00
|
|
|
|
scrollHandler = () => {
|
2025-10-10 06:58:48 +02:00
|
|
|
|
const isAtBottom =
|
2025-09-23 20:54:50 +02:00
|
|
|
|
container.scrollHeight - container.scrollTop <= container.clientHeight + 50
|
2025-10-10 06:58:48 +02:00
|
|
|
|
|
2025-09-23 20:54:50 +02:00
|
|
|
|
if (!isAtBottom && autoScroll.value) {
|
|
|
|
|
|
autoScroll.value = false
|
|
|
|
|
|
}
|
2025-10-10 17:01:15 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
container.addEventListener('scroll', scrollHandler, { passive: true })
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// Nettoyer au démontage
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
if (logContainer.value && scrollHandler) {
|
|
|
|
|
|
logContainer.value.removeEventListener('scroll', scrollHandler)
|
|
|
|
|
|
}
|
2025-09-23 20:54:50 +02:00
|
|
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.log-viewer {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2025-10-18 22:14:41 +02:00
|
|
|
|
height: calc(100vh - 60px); /* Hauteur viewport - nav */
|
2025-10-11 00:33:13 +02:00
|
|
|
|
width: 100%;
|
2025-09-23 20:54:50 +02:00
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
background: #1e1e1e;
|
|
|
|
|
|
color: #d4d4d4;
|
|
|
|
|
|
font-family: 'Consolas', 'Monaco', monospace;
|
|
|
|
|
|
box-sizing: border-box;
|
2025-10-18 22:14:41 +02:00
|
|
|
|
overflow: hidden; /* Empêcher le scroll sur le conteneur principal */
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
padding: 1rem 1.5rem;
|
|
|
|
|
|
background: #252526;
|
|
|
|
|
|
border-bottom: 1px solid #3e3e42;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 0.5rem;
|
2025-10-18 22:14:41 +02:00
|
|
|
|
flex-shrink: 0; /* Ne pas réduire le header */
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header h2 {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
color: #ffffff;
|
|
|
|
|
|
font-size: 1.2rem;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.header {
|
|
|
|
|
|
padding: 0.75rem 1rem;
|
|
|
|
|
|
}
|
2025-10-10 06:58:48 +02:00
|
|
|
|
|
2025-09-23 20:54:50 +02:00
|
|
|
|
.header h2 {
|
|
|
|
|
|
font-size: 1rem;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.controls {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.controls {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
button {
|
|
|
|
|
|
padding: 0.5rem 1rem;
|
|
|
|
|
|
background: #3c3c3c;
|
|
|
|
|
|
color: #d4d4d4;
|
|
|
|
|
|
border: 1px solid #555;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
|
transition: all 0.2s;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
button {
|
|
|
|
|
|
padding: 0.4rem 0.7rem;
|
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
button:hover {
|
|
|
|
|
|
background: #505050;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
button.active {
|
|
|
|
|
|
background: #0e639c;
|
|
|
|
|
|
border-color: #1177bb;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.filter {
|
|
|
|
|
|
padding: 0.5rem;
|
|
|
|
|
|
background: #3c3c3c;
|
|
|
|
|
|
color: #d4d4d4;
|
|
|
|
|
|
border: 1px solid #555;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
cursor: pointer;
|
2025-10-10 06:58:48 +02:00
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-18 22:14:41 +02:00
|
|
|
|
.filter.log-level {
|
2025-10-10 06:58:48 +02:00
|
|
|
|
background: #1e3a5f;
|
|
|
|
|
|
border-color: #569cd6;
|
|
|
|
|
|
font-weight: bold;
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.filter {
|
|
|
|
|
|
padding: 0.4rem;
|
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 23:59:30 +02:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-23 20:54:50 +02:00
|
|
|
|
.log-container {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
|
background: #1e1e1e;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.log-entry {
|
2025-10-10 06:58:48 +02:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2025-09-23 20:54:50 +02:00
|
|
|
|
padding: 0.5rem;
|
|
|
|
|
|
margin-bottom: 0.25rem;
|
|
|
|
|
|
border-left: 3px solid transparent;
|
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
|
line-height: 1.4;
|
2025-10-10 06:58:48 +02:00
|
|
|
|
gap: 0.5rem;
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.log-entry {
|
|
|
|
|
|
padding: 0.75rem 0.5rem;
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
border-left-width: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.log-entry:hover {
|
|
|
|
|
|
background: #2d2d30;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.log-entry.is-history {
|
|
|
|
|
|
opacity: 0.7;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 06:58:48 +02:00
|
|
|
|
.log-header {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.log-header {
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.log-content {
|
|
|
|
|
|
padding-left: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-23 20:54:50 +02:00
|
|
|
|
.timestamp {
|
|
|
|
|
|
color: #858585;
|
|
|
|
|
|
font-weight: 500;
|
2025-10-10 06:58:48 +02:00
|
|
|
|
flex-shrink: 0;
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.timestamp {
|
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.level {
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
padding: 0.1rem 0.5rem;
|
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
|
text-align: center;
|
2025-10-10 06:58:48 +02:00
|
|
|
|
flex-shrink: 0;
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.level {
|
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
|
padding: 0.2rem 0.6rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.target {
|
|
|
|
|
|
color: #4ec9b0;
|
|
|
|
|
|
font-style: italic;
|
2025-10-10 06:58:48 +02:00
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
flex-shrink: 1;
|
|
|
|
|
|
min-width: 0;
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.target {
|
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
|
color: #6eb8a5;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.message {
|
|
|
|
|
|
color: #d4d4d4;
|
|
|
|
|
|
word-break: break-word;
|
2025-10-01 20:49:50 +02:00
|
|
|
|
text-align: left;
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 06:58:48 +02:00
|
|
|
|
.log-details {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.log-summary {
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
color: #569cd6;
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
user-select: none;
|
2025-10-10 23:59:30 +02:00
|
|
|
|
display: inline-block;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
min-height: 1em;
|
2025-10-10 06:58:48 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.log-summary::-webkit-details-marker {
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.log-summary::marker {
|
|
|
|
|
|
content: '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.log-summary::before {
|
2025-10-10 23:59:30 +02:00
|
|
|
|
content: '▶ Afficher plus';
|
2025-10-10 06:58:48 +02:00
|
|
|
|
display: inline-block;
|
|
|
|
|
|
transition: transform 0.2s;
|
|
|
|
|
|
color: #569cd6;
|
2025-10-10 23:59:30 +02:00
|
|
|
|
font-size: 0.85em;
|
|
|
|
|
|
font-style: italic;
|
2025-10-10 06:58:48 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.log-details[open] .log-summary::before {
|
2025-10-10 23:59:30 +02:00
|
|
|
|
content: '▼ Afficher moins';
|
2025-10-10 06:58:48 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.log-summary:hover::before {
|
|
|
|
|
|
color: #6fa8dc;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 23:59:30 +02:00
|
|
|
|
.truncated-preview {
|
2025-10-10 06:58:48 +02:00
|
|
|
|
color: #d4d4d4;
|
|
|
|
|
|
font-family: 'Consolas', 'Monaco', monospace;
|
|
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
|
word-break: break-word;
|
2025-10-10 23:59:30 +02:00
|
|
|
|
margin-bottom: 0.25rem;
|
2025-10-10 06:58:48 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.full-message {
|
|
|
|
|
|
margin-top: 0.5rem;
|
|
|
|
|
|
padding-left: 1.5em;
|
|
|
|
|
|
border-left: 2px solid #569cd6;
|
|
|
|
|
|
padding-top: 0.5rem;
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.markdown-content {
|
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.markdown-content :deep(code) {
|
|
|
|
|
|
background: #3c3c3c;
|
|
|
|
|
|
padding: 0.1rem 0.3rem;
|
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
|
font-family: 'Consolas', 'Monaco', monospace;
|
|
|
|
|
|
font-size: 0.85em;
|
|
|
|
|
|
color: #ce9178;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.markdown-content :deep(pre) {
|
|
|
|
|
|
background: #2d2d30;
|
2025-10-06 07:31:03 +02:00
|
|
|
|
padding: 0.75rem;
|
2025-09-23 20:54:50 +02:00
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
overflow-x: auto;
|
2025-10-06 07:31:03 +02:00
|
|
|
|
margin: 0.5rem 0;
|
|
|
|
|
|
border: 1px solid #3e3e42;
|
|
|
|
|
|
max-height: 400px;
|
|
|
|
|
|
overflow-y: auto;
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.markdown-content :deep(pre code) {
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
color: #d4d4d4;
|
2025-10-06 07:31:03 +02:00
|
|
|
|
font-size: 0.85em;
|
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Coloration pour les blocs XML */
|
|
|
|
|
|
.markdown-content :deep(pre code.language-xml) {
|
|
|
|
|
|
color: #ce9178;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 06:58:48 +02:00
|
|
|
|
/* Style pour les images */
|
|
|
|
|
|
.markdown-content :deep(img) {
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
height: auto;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
margin: 0.5rem 0;
|
|
|
|
|
|
border: 1px solid #3e3e42;
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 23:59:30 +02:00
|
|
|
|
/* 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-06 07:31:03 +02:00
|
|
|
|
/* Scrollbar pour les blocs de code longs */
|
|
|
|
|
|
.markdown-content :deep(pre)::-webkit-scrollbar {
|
|
|
|
|
|
width: 8px;
|
|
|
|
|
|
height: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.markdown-content :deep(pre)::-webkit-scrollbar-track {
|
|
|
|
|
|
background: #1e1e1e;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.markdown-content :deep(pre)::-webkit-scrollbar-thumb {
|
|
|
|
|
|
background: #424242;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.markdown-content :deep(pre)::-webkit-scrollbar-thumb:hover {
|
|
|
|
|
|
background: #4e4e4e;
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.markdown-content :deep(strong) {
|
|
|
|
|
|
color: #ffffff;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.markdown-content :deep(em) {
|
|
|
|
|
|
color: #dcdcaa;
|
|
|
|
|
|
font-style: italic;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.markdown-content :deep(a) {
|
|
|
|
|
|
color: #569cd6;
|
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.markdown-content :deep(a:hover) {
|
|
|
|
|
|
text-decoration: underline;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.markdown-content :deep(p) {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
display: inline;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.markdown-content :deep(ul),
|
|
|
|
|
|
.markdown-content :deep(ol) {
|
|
|
|
|
|
margin: 0.25rem 0;
|
|
|
|
|
|
padding-left: 1.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 06:58:48 +02:00
|
|
|
|
/* Level colors - Classés par ordre de gravité */
|
|
|
|
|
|
.level-error {
|
|
|
|
|
|
border-left-color: #f48771;
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 06:58:48 +02:00
|
|
|
|
.level-error .level {
|
|
|
|
|
|
background: #5a1e1e;
|
|
|
|
|
|
color: #f48771;
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 06:58:48 +02:00
|
|
|
|
.level-warn {
|
|
|
|
|
|
border-left-color: #dcdcaa;
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 06:58:48 +02:00
|
|
|
|
.level-warn .level {
|
|
|
|
|
|
background: #4d4d2a;
|
|
|
|
|
|
color: #dcdcaa;
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.level-info {
|
|
|
|
|
|
border-left-color: #4ec9b0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.level-info .level {
|
|
|
|
|
|
background: #1e4d42;
|
|
|
|
|
|
color: #4ec9b0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 06:58:48 +02:00
|
|
|
|
.level-debug {
|
|
|
|
|
|
border-left-color: #569cd6;
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 06:58:48 +02:00
|
|
|
|
.level-debug .level {
|
|
|
|
|
|
background: #1e3a5f;
|
|
|
|
|
|
color: #569cd6;
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 06:58:48 +02:00
|
|
|
|
.level-trace {
|
|
|
|
|
|
border-left-color: #808080;
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 06:58:48 +02:00
|
|
|
|
.level-trace .level {
|
|
|
|
|
|
background: #3a3a3a;
|
|
|
|
|
|
color: #a0a0a0;
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.empty-state {
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
padding: 3rem;
|
|
|
|
|
|
color: #858585;
|
|
|
|
|
|
font-size: 1.1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.loading-state {
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
padding: 3rem;
|
|
|
|
|
|
color: #569cd6;
|
|
|
|
|
|
font-size: 1.1rem;
|
2026-04-06 09:50:54 +02:00
|
|
|
|
animation: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Respect prefers-reduced-motion */
|
|
|
|
|
|
@media (prefers-reduced-motion: no-preference) {
|
|
|
|
|
|
.loading-state {
|
|
|
|
|
|
animation: pulse 1.5s ease-in-out infinite;
|
|
|
|
|
|
}
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@keyframes pulse {
|
|
|
|
|
|
0%, 100% { opacity: 1; }
|
|
|
|
|
|
50% { opacity: 0.5; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.footer {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
padding: 0.75rem 1.5rem;
|
|
|
|
|
|
background: #252526;
|
|
|
|
|
|
border-top: 1px solid #3e3e42;
|
|
|
|
|
|
font-size: 0.9rem;
|
2025-10-10 06:58:48 +02:00
|
|
|
|
gap: 1rem;
|
2025-10-18 22:14:41 +02:00
|
|
|
|
flex-shrink: 0; /* Ne pas réduire le footer */
|
2025-09-23 20:54:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.footer {
|
|
|
|
|
|
padding: 0.6rem 1rem;
|
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.status {
|
|
|
|
|
|
color: #f48771;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.status.connected {
|
|
|
|
|
|
color: #4ec9b0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 06:58:48 +02:00
|
|
|
|
.server-info {
|
|
|
|
|
|
color: #569cd6;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-23 20:54:50 +02:00
|
|
|
|
.count {
|
|
|
|
|
|
color: #858585;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 23:59:30 +02:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-23 20:54:50 +02:00
|
|
|
|
/* Scrollbar styling */
|
|
|
|
|
|
.log-container::-webkit-scrollbar {
|
|
|
|
|
|
width: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.log-container::-webkit-scrollbar-track {
|
|
|
|
|
|
background: #1e1e1e;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.log-container::-webkit-scrollbar-thumb {
|
|
|
|
|
|
background: #424242;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.log-container::-webkit-scrollbar-thumb:hover {
|
|
|
|
|
|
background: #4e4e4e;
|
|
|
|
|
|
}
|
2025-10-10 06:58:48 +02:00
|
|
|
|
</style>
|