Files
pmomusic/pmoapp/webapp/src/components/LogView.vue

1069 lines
24 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>
<!-- Sélection du niveau côté serveur -->
<select v-model="serverLogLevel" @change="updateServerLogLevel" class="filter server-level">
<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>
<!-- Filtre côté client -->
<select v-model="levelFilter" class="filter">
<option value="ALL">All Levels</option>
<option value="ERROR">ERROR</option>
<option value="WARN">WARN</option>
<option value="INFO">INFO</option>
<option value="DEBUG">DEBUG</option>
<option value="TRACE">TRACE</option>
</select>
<!-- 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>
</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 }]"
>
<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">
<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>
</div>
</div>
</div>
<div v-if="isLoadingHistory" class="loading-state">
Loading history...
</div>
<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>
<span class="server-info">Server level: {{ serverLogLevel }}</span>
<span class="count">
{{ filteredLogs.length }} log{{ filteredLogs.length !== 1 ? 's' : '' }}
<span v-if="searchQuery.trim()" class="search-results">
({{ filteredLogs.length }} / {{ logs.length }} matching "{{ searchQuery }}")
</span>
</span>
</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)
const levelFilter = ref('ALL')
const serverLogLevel = ref('TRACE')
const searchQuery = ref('')
const logContainer = ref(null)
let eventSource = null
let historyLoaded = false
const seenLogIds = new Set() // Pour détecter les duplicatas
// Ordre de gravité des niveaux (du plus grave au moins grave)
const levelOrder = {
'ERROR': 0,
'WARN': 1,
'INFO': 2,
'DEBUG': 3,
'TRACE': 4
}
// Pré-calculer filteredLogs de manière optimisée avec recherche
const filteredLogs = computed(() => {
let filtered = logs.value
// Filtre par niveau
if (levelFilter.value !== 'ALL') {
const filter = levelFilter.value
filtered = filtered.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
async function updateServerLogLevel() {
try {
const response = await fetch('/api/log_setup', {
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)
} 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 {
const response = await fetch('/api/log_setup')
if (response.ok) {
const data = await response.json()
serverLogLevel.value = data.current_level
}
} catch (error) {
console.error('Error loading log level:', error)
}
}
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
})
}
// Pré-traiter un log : calculer HTML, troncature, etc. UNE SEULE FOIS
function preprocessLog(message) {
// ÉTAPE 1: Déterminer si trop long
const firstLineEnd = message.indexOf('\n')
const isTooLong = firstLineEnd !== -1 || message.length > 200
// É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
// ÉTAPE 3: Pré-processing pour détecter et protéger le XML, images et audio
let processedText = 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 = processedText.match(/<\?xml[\s\S]*$/)
if (xmlStartMatch) {
const xmlContent = xmlStartMatch[0]
const beforeXml = processedText.substring(0, processedText.indexOf(xmlContent))
processedText = beforeXml + '\n```xml\n' + xmlContent + '\n```\n'
} else {
const xmlMatch = processedText.match(/<([a-zA-Z][a-zA-Z0-9:-]*)[>\s][\s\S]*/)
if (xmlMatch) {
const xmlContent = xmlMatch[0]
const beforeXml = processedText.substring(0, processedText.indexOf(xmlContent))
processedText = beforeXml + '\n```xml\n' + xmlContent + '\n```\n'
}
}
}
// ÉTAPE 5: Convertir markdown en HTML
const rawHtml = marked.parse(processedText, { async: false })
// ÉTAPE 6: Nettoyer pour la sécurité
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 = `<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
}
return {
isTooLong,
truncatedMessage,
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, '<mark class="search-highlight">$1</mark>')
}
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)
// 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}`
// Ignorer les duplicatas
if (seenLogIds.has(logId)) {
return
}
seenLogIds.add(logId)
// Marquer les logs historiques
if (!historyLoaded) {
logEntry.isHistory = true
}
// PRÉ-TRAITER le log UNE SEULE FOIS à la réception
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)
// 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)
}
scrollToBottom()
} catch (error) {
console.error('Failed to parse log entry:', error)
}
}
eventSource.onerror = () => {
isConnected.value = false
isLoadingHistory.value = false
console.error('SSE connection error')
// 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)
if (!historyLoaded) {
historyTimeout = setTimeout(() => {
historyLoaded = true
isLoadingHistory.value = false
console.log('History loaded, now streaming live logs')
}, 500)
}
}
}
onMounted(() => {
loadServerLogLevel()
connectSSE()
})
onUnmounted(() => {
if (eventSource) {
eventSource.close()
}
})
// Désactiver auto-scroll si l'utilisateur scroll manuellement
let scrollHandler = null
watch(logContainer, (container, oldContainer) => {
// Nettoyer l'ancien listener si existant
if (oldContainer && scrollHandler) {
oldContainer.removeEventListener('scroll', scrollHandler)
}
if (!container) return
scrollHandler = () => {
const isAtBottom =
container.scrollHeight - container.scrollTop <= container.clientHeight + 50
if (!isAtBottom && autoScroll.value) {
autoScroll.value = false
}
}
container.addEventListener('scroll', scrollHandler, { passive: true })
})
// Nettoyer au démontage
onUnmounted(() => {
if (logContainer.value && scrollHandler) {
logContainer.value.removeEventListener('scroll', scrollHandler)
}
})
</script>
<style scoped>
.log-viewer {
display: flex;
flex-direction: column;
min-height: calc(100vh - 60px); /* Hauteur viewport - nav */
width: 100%;
margin: 0;
padding: 0;
background: #1e1e1e;
color: #d4d4d4;
font-family: 'Consolas', 'Monaco', monospace;
box-sizing: border-box;
}
.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;
}
.header h2 {
margin: 0;
color: #ffffff;
font-size: 1.2rem;
flex-shrink: 0;
}
@media (max-width: 768px) {
.header {
padding: 0.75rem 1rem;
}
.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;
font-size: 0.9rem;
}
.filter.server-level {
background: #1e3a5f;
border-color: #569cd6;
font-weight: bold;
}
@media (max-width: 768px) {
.filter {
padding: 0.4rem;
font-size: 0.8rem;
flex: 1;
min-width: 0;
}
}
.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;
padding: 1rem;
background: #1e1e1e;
}
.log-entry {
display: flex;
flex-direction: column;
padding: 0.5rem;
margin-bottom: 0.25rem;
border-left: 3px solid transparent;
font-size: 0.9rem;
line-height: 1.4;
gap: 0.5rem;
}
@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;
}
.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;
}
.timestamp {
color: #858585;
font-weight: 500;
flex-shrink: 0;
}
@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;
flex-shrink: 0;
}
@media (max-width: 768px) {
.level {
font-size: 0.75rem;
padding: 0.2rem 0.6rem;
}
}
.target {
color: #4ec9b0;
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 1;
min-width: 0;
}
@media (max-width: 768px) {
.target {
font-size: 0.8rem;
color: #6eb8a5;
}
}
.message {
color: #d4d4d4;
word-break: break-word;
text-align: left;
}
.log-details {
margin: 0;
}
.log-summary {
cursor: pointer;
color: #569cd6;
list-style: none;
user-select: none;
display: inline-block;
margin: 0;
padding: 0;
min-height: 1em;
}
.log-summary::-webkit-details-marker {
display: none;
}
.log-summary::marker {
content: '';
}
.log-summary::before {
content: '▶ Afficher plus';
display: inline-block;
transition: transform 0.2s;
color: #569cd6;
font-size: 0.85em;
font-style: italic;
}
.log-details[open] .log-summary::before {
content: '▼ Afficher moins';
}
.log-summary:hover::before {
color: #6fa8dc;
}
.truncated-preview {
color: #d4d4d4;
font-family: 'Consolas', 'Monaco', monospace;
white-space: pre-wrap;
word-break: break-word;
margin-bottom: 0.25rem;
}
.full-message {
margin-top: 0.5rem;
padding-left: 1.5em;
border-left: 2px solid #569cd6;
padding-top: 0.5rem;
}
.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;
padding: 0.75rem;
border-radius: 4px;
overflow-x: auto;
margin: 0.5rem 0;
border: 1px solid #3e3e42;
max-height: 400px;
overflow-y: auto;
}
.markdown-content :deep(pre code) {
background: transparent;
padding: 0;
color: #d4d4d4;
font-size: 0.85em;
line-height: 1.5;
display: block;
}
/* Coloration pour les blocs XML */
.markdown-content :deep(pre code.language-xml) {
color: #ce9178;
}
/* 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;
}
/* 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;
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;
}
.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;
}
/* Level colors - Classés par ordre de gravité */
.level-error {
border-left-color: #f48771;
}
.level-error .level {
background: #5a1e1e;
color: #f48771;
}
.level-warn {
border-left-color: #dcdcaa;
}
.level-warn .level {
background: #4d4d2a;
color: #dcdcaa;
}
.level-info {
border-left-color: #4ec9b0;
}
.level-info .level {
background: #1e4d42;
color: #4ec9b0;
}
.level-debug {
border-left-color: #569cd6;
}
.level-debug .level {
background: #1e3a5f;
color: #569cd6;
}
.level-trace {
border-left-color: #808080;
}
.level-trace .level {
background: #3a3a3a;
color: #a0a0a0;
}
.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;
animation: pulse 1.5s ease-in-out infinite;
}
@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;
gap: 1rem;
}
@media (max-width: 768px) {
.footer {
padding: 0.6rem 1rem;
font-size: 0.8rem;
}
}
.status {
color: #f48771;
}
.status.connected {
color: #4ec9b0;
}
.server-info {
color: #569cd6;
font-weight: bold;
}
.count {
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;
}
.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;
}
</style>