Amélioration du visualiseur de log web
This commit is contained in:
@@ -7,13 +7,24 @@
|
|||||||
{{ autoScroll ? '📌 Auto-scroll ON' : '📌 Auto-scroll OFF' }}
|
{{ autoScroll ? '📌 Auto-scroll ON' : '📌 Auto-scroll OFF' }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="clearLogs">🗑️ Clear</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">
|
<select v-model="levelFilter" class="filter">
|
||||||
<option value="ALL">All Levels</option>
|
<option value="ALL">All Levels</option>
|
||||||
<option value="TRACE">TRACE</option>
|
|
||||||
<option value="DEBUG">DEBUG</option>
|
|
||||||
<option value="INFO">INFO</option>
|
|
||||||
<option value="WARN">WARN</option>
|
|
||||||
<option value="ERROR">ERROR</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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,16 +35,28 @@
|
|||||||
:key="index"
|
:key="index"
|
||||||
:class="['log-entry', `level-${log.level.toLowerCase()}`, { 'is-history': log.isHistory }]"
|
:class="['log-entry', `level-${log.level.toLowerCase()}`, { 'is-history': log.isHistory }]"
|
||||||
>
|
>
|
||||||
<span class="timestamp">{{ formatTimestamp(log.timestamp) }}</span>
|
<div class="log-header">
|
||||||
<span class="level">{{ log.level }}</span>
|
<span class="timestamp">{{ formatTimestamp(log.timestamp) }}</span>
|
||||||
<span class="target">{{ log.target }}</span>
|
<span class="level">{{ log.level }}</span>
|
||||||
<span class="message markdown-content" v-html="renderMarkdown(log.message)"></span>
|
<span class="target">{{ log.target }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-content">
|
||||||
|
<div class="message markdown-content">
|
||||||
|
<details v-if="isTooLong(log.message)" class="log-details">
|
||||||
|
<summary class="log-summary">
|
||||||
|
<span class="truncated-text">{{ truncateMessage(log.message) }}</span>
|
||||||
|
</summary>
|
||||||
|
<div class="full-message" v-html="renderMarkdown(log.message)"></div>
|
||||||
|
</details>
|
||||||
|
<div v-else v-html="renderMarkdown(log.message)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isLoadingHistory" class="loading-state">
|
<div v-if="isLoadingHistory" class="loading-state">
|
||||||
⏳ Loading history...
|
⏳ Loading history...
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="filteredLogs.length === 0" class="empty-state">
|
<div v-else-if="filteredLogs.length === 0" class="empty-state">
|
||||||
{{ isConnected ? 'Waiting for logs...' : 'Connecting to log stream...' }}
|
{{ isConnected ? 'Waiting for logs...' : 'Connecting to log stream...' }}
|
||||||
</div>
|
</div>
|
||||||
@@ -43,6 +66,7 @@
|
|||||||
<span :class="['status', { connected: isConnected }]">
|
<span :class="['status', { connected: isConnected }]">
|
||||||
{{ isConnected ? '🟢 Connected' : '🔴 Disconnected' }}
|
{{ isConnected ? '🟢 Connected' : '🔴 Disconnected' }}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="server-info">Server level: {{ serverLogLevel }}</span>
|
||||||
<span class="count">{{ filteredLogs.length }} logs</span>
|
<span class="count">{{ filteredLogs.length }} logs</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,11 +88,21 @@ const autoScroll = ref(true)
|
|||||||
const isConnected = ref(false)
|
const isConnected = ref(false)
|
||||||
const isLoadingHistory = ref(true)
|
const isLoadingHistory = ref(true)
|
||||||
const levelFilter = ref('ALL')
|
const levelFilter = ref('ALL')
|
||||||
|
const serverLogLevel = ref('TRACE')
|
||||||
const logContainer = ref(null)
|
const logContainer = ref(null)
|
||||||
let eventSource = null
|
let eventSource = null
|
||||||
let historyLoaded = false
|
let historyLoaded = false
|
||||||
const seenLogIds = new Set() // Pour détecter les duplicatas
|
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
|
||||||
|
}
|
||||||
|
|
||||||
const filteredLogs = computed(() => {
|
const filteredLogs = computed(() => {
|
||||||
if (levelFilter.value === 'ALL') {
|
if (levelFilter.value === 'ALL') {
|
||||||
return logs.value
|
return logs.value
|
||||||
@@ -76,6 +110,43 @@ const filteredLogs = computed(() => {
|
|||||||
return logs.value.filter(log => log.level === levelFilter.value)
|
return logs.value.filter(log => log.level === levelFilter.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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) {
|
function formatTimestamp(timestamp) {
|
||||||
const date = new Date(timestamp.secs_since_epoch * 1000)
|
const date = new Date(timestamp.secs_since_epoch * 1000)
|
||||||
return date.toLocaleTimeString('fr-FR', {
|
return date.toLocaleTimeString('fr-FR', {
|
||||||
@@ -86,6 +157,21 @@ function formatTimestamp(timestamp) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTooLong(message) {
|
||||||
|
// Un message est trop long s'il a plus d'une ligne OU plus de 200 caractères
|
||||||
|
const firstLineEnd = message.indexOf('\n')
|
||||||
|
return firstLineEnd !== -1 || message.length > 200
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateMessage(message) {
|
||||||
|
// Prendre la première ligne, ou les 200 premiers caractères si pas de saut de ligne
|
||||||
|
const firstLineEnd = message.indexOf('\n')
|
||||||
|
if (firstLineEnd !== -1) {
|
||||||
|
return message.substring(0, firstLineEnd).trim()
|
||||||
|
}
|
||||||
|
return message.substring(0, 200).trim()
|
||||||
|
}
|
||||||
|
|
||||||
function renderMarkdown(text) {
|
function renderMarkdown(text) {
|
||||||
// ÉTAPE 1 : Pré-processing pour détecter et protéger le XML
|
// ÉTAPE 1 : Pré-processing pour détecter et protéger le XML
|
||||||
let processedText = text
|
let processedText = text
|
||||||
@@ -115,13 +201,20 @@ function renderMarkdown(text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ÉTAPE 2 : Convertir markdown en HTML
|
// ÉTAPE 2 : Détecter et transformer les liens d'images
|
||||||
|
// Pattern pour détecter les URLs d'images (png, jpg, jpeg, gif, webp, svg)
|
||||||
|
const imageUrlPattern = /(https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|svg)(?:\?[^\s]*)?)/gi
|
||||||
|
processedText = processedText.replace(imageUrlPattern, (match) => {
|
||||||
|
return `\n\n`
|
||||||
|
})
|
||||||
|
|
||||||
|
// ÉTAPE 3 : Convertir markdown en HTML
|
||||||
const rawHtml = marked.parse(processedText, { async: false })
|
const rawHtml = marked.parse(processedText, { async: false })
|
||||||
|
|
||||||
// ÉTAPE 3 : Nettoyer pour la sécurité
|
// ÉTAPE 4 : Nettoyer pour la sécurité
|
||||||
return DOMPurify.sanitize(rawHtml, {
|
return DOMPurify.sanitize(rawHtml, {
|
||||||
ALLOWED_TAGS: ['strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span'],
|
ALLOWED_TAGS: ['strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span', 'img'],
|
||||||
ALLOWED_ATTR: ['href', 'target', 'class']
|
ALLOWED_ATTR: ['href', 'target', 'class', 'src', 'alt', 'title']
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,23 +250,23 @@ function connectSSE() {
|
|||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const logEntry = JSON.parse(event.data)
|
const logEntry = JSON.parse(event.data)
|
||||||
|
|
||||||
// Créer un ID unique basé sur timestamp + message + target
|
// 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}`
|
const logId = `${logEntry.timestamp.secs_since_epoch}-${logEntry.timestamp.nanos_since_epoch}-${logEntry.message}-${logEntry.target}`
|
||||||
|
|
||||||
// Ignorer les duplicatas
|
// Ignorer les duplicatas
|
||||||
if (seenLogIds.has(logId)) {
|
if (seenLogIds.has(logId)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
seenLogIds.add(logId)
|
seenLogIds.add(logId)
|
||||||
|
|
||||||
// Marquer les logs historiques
|
// Marquer les logs historiques
|
||||||
if (!historyLoaded) {
|
if (!historyLoaded) {
|
||||||
logEntry.isHistory = true
|
logEntry.isHistory = true
|
||||||
}
|
}
|
||||||
|
|
||||||
logs.value.push(logEntry)
|
logs.value.push(logEntry)
|
||||||
|
|
||||||
// Limiter à 1000 logs en mémoire
|
// Limiter à 1000 logs en mémoire
|
||||||
if (logs.value.length > 1000) {
|
if (logs.value.length > 1000) {
|
||||||
const removed = logs.value.shift()
|
const removed = logs.value.shift()
|
||||||
@@ -181,7 +274,7 @@ function connectSSE() {
|
|||||||
const removedId = `${removed.timestamp.secs_since_epoch}-${removed.timestamp.nanos_since_epoch}-${removed.message}-${removed.target}`
|
const removedId = `${removed.timestamp.secs_since_epoch}-${removed.timestamp.nanos_since_epoch}-${removed.message}-${removed.target}`
|
||||||
seenLogIds.delete(removedId)
|
seenLogIds.delete(removedId)
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse log entry:', error)
|
console.error('Failed to parse log entry:', error)
|
||||||
@@ -192,7 +285,7 @@ function connectSSE() {
|
|||||||
isConnected.value = false
|
isConnected.value = false
|
||||||
isLoadingHistory.value = false
|
isLoadingHistory.value = false
|
||||||
console.error('SSE connection error')
|
console.error('SSE connection error')
|
||||||
|
|
||||||
// Reconnexion automatique après 3 secondes
|
// Reconnexion automatique après 3 secondes
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (eventSource.readyState === EventSource.CLOSED) {
|
if (eventSource.readyState === EventSource.CLOSED) {
|
||||||
@@ -209,7 +302,7 @@ function connectSSE() {
|
|||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
clearTimeout(historyTimeout)
|
clearTimeout(historyTimeout)
|
||||||
originalOnMessage(event)
|
originalOnMessage(event)
|
||||||
|
|
||||||
if (!historyLoaded) {
|
if (!historyLoaded) {
|
||||||
historyTimeout = setTimeout(() => {
|
historyTimeout = setTimeout(() => {
|
||||||
historyLoaded = true
|
historyLoaded = true
|
||||||
@@ -221,6 +314,7 @@ function connectSSE() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
loadServerLogLevel()
|
||||||
connectSSE()
|
connectSSE()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -233,11 +327,11 @@ onUnmounted(() => {
|
|||||||
// Désactiver auto-scroll si l'utilisateur scroll manuellement
|
// Désactiver auto-scroll si l'utilisateur scroll manuellement
|
||||||
watch(logContainer, (container) => {
|
watch(logContainer, (container) => {
|
||||||
if (!container) return
|
if (!container) return
|
||||||
|
|
||||||
container.addEventListener('scroll', () => {
|
container.addEventListener('scroll', () => {
|
||||||
const isAtBottom =
|
const isAtBottom =
|
||||||
container.scrollHeight - container.scrollTop <= container.clientHeight + 50
|
container.scrollHeight - container.scrollTop <= container.clientHeight + 50
|
||||||
|
|
||||||
if (!isAtBottom && autoScroll.value) {
|
if (!isAtBottom && autoScroll.value) {
|
||||||
autoScroll.value = false
|
autoScroll.value = false
|
||||||
}
|
}
|
||||||
@@ -281,7 +375,7 @@ watch(logContainer, (container) => {
|
|||||||
.header {
|
.header {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header h2 {
|
.header h2 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -338,6 +432,13 @@ button.active {
|
|||||||
border: 1px solid #555;
|
border: 1px solid #555;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter.server-level {
|
||||||
|
background: #1e3a5f;
|
||||||
|
border-color: #569cd6;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -357,20 +458,18 @@ button.active {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.log-entry {
|
.log-entry {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 130px 80px 200px 1fr;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.log-entry {
|
.log-entry {
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 0.3rem;
|
|
||||||
padding: 0.75rem 0.5rem;
|
padding: 0.75rem 0.5rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
border-left-width: 4px;
|
border-left-width: 4px;
|
||||||
@@ -385,15 +484,32 @@ button.active {
|
|||||||
opacity: 0.7;
|
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 {
|
.timestamp {
|
||||||
color: #858585;
|
color: #858585;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.timestamp {
|
.timestamp {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
order: 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,12 +519,11 @@ button.active {
|
|||||||
padding: 0.1rem 0.5rem;
|
padding: 0.1rem 0.5rem;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.level {
|
.level {
|
||||||
order: 2;
|
|
||||||
width: fit-content;
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
padding: 0.2rem 0.6rem;
|
padding: 0.2rem 0.6rem;
|
||||||
}
|
}
|
||||||
@@ -417,11 +532,15 @@ button.active {
|
|||||||
.target {
|
.target {
|
||||||
color: #4ec9b0;
|
color: #4ec9b0;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.target {
|
.target {
|
||||||
order: 3;
|
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #6eb8a5;
|
color: #6eb8a5;
|
||||||
}
|
}
|
||||||
@@ -433,11 +552,61 @@ button.active {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
.log-details {
|
||||||
.message {
|
margin: 0;
|
||||||
order: 4;
|
}
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
.log-summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #569cd6;
|
||||||
|
list-style: none;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-summary::marker {
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-summary::before {
|
||||||
|
content: '▶';
|
||||||
|
display: inline-block;
|
||||||
|
width: 1em;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
color: #569cd6;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-details[open] .log-summary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-summary:hover {
|
||||||
|
color: #6fa8dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-summary:hover::before {
|
||||||
|
color: #6fa8dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.truncated-text {
|
||||||
|
color: #d4d4d4;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-message {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
border-left: 2px solid #569cd6;
|
||||||
|
padding-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content {
|
.markdown-content {
|
||||||
@@ -478,6 +647,16 @@ button.active {
|
|||||||
color: #ce9178;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/* Scrollbar pour les blocs de code longs */
|
/* Scrollbar pour les blocs de code longs */
|
||||||
.markdown-content :deep(pre)::-webkit-scrollbar {
|
.markdown-content :deep(pre)::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
@@ -527,32 +706,14 @@ button.active {
|
|||||||
padding-left: 1.5rem;
|
padding-left: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Level colors */
|
/* Level colors - Classés par ordre de gravité */
|
||||||
.level-trace {
|
.level-error {
|
||||||
border-left-color: #808080;
|
border-left-color: #f48771;
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-trace .level {
|
.level-error .level {
|
||||||
background: #3a3a3a;
|
background: #5a1e1e;
|
||||||
color: #a0a0a0;
|
color: #f48771;
|
||||||
}
|
|
||||||
|
|
||||||
.level-debug {
|
|
||||||
border-left-color: #569cd6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.level-debug .level {
|
|
||||||
background: #1e3a5f;
|
|
||||||
color: #569cd6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.level-info {
|
|
||||||
border-left-color: #4ec9b0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.level-info .level {
|
|
||||||
background: #1e4d42;
|
|
||||||
color: #4ec9b0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-warn {
|
.level-warn {
|
||||||
@@ -564,13 +725,31 @@ button.active {
|
|||||||
color: #dcdcaa;
|
color: #dcdcaa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-error {
|
.level-info {
|
||||||
border-left-color: #f48771;
|
border-left-color: #4ec9b0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-error .level {
|
.level-info .level {
|
||||||
background: #5a1e1e;
|
background: #1e4d42;
|
||||||
color: #f48771;
|
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 {
|
.empty-state {
|
||||||
@@ -600,6 +779,7 @@ button.active {
|
|||||||
background: #252526;
|
background: #252526;
|
||||||
border-top: 1px solid #3e3e42;
|
border-top: 1px solid #3e3e42;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -617,6 +797,11 @@ button.active {
|
|||||||
color: #4ec9b0;
|
color: #4ec9b0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-info {
|
||||||
|
color: #569cd6;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
.count {
|
.count {
|
||||||
color: #858585;
|
color: #858585;
|
||||||
}
|
}
|
||||||
@@ -638,4 +823,4 @@ button.active {
|
|||||||
.log-container::-webkit-scrollbar-thumb:hover {
|
.log-container::-webkit-scrollbar-thumb:hover {
|
||||||
background: #4e4e4e;
|
background: #4e4e4e;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -72,4 +72,4 @@ pub mod server;
|
|||||||
pub mod logs;
|
pub mod logs;
|
||||||
|
|
||||||
pub use server::{Server, ServerBuilder, ServerInfo};
|
pub use server::{Server, ServerBuilder, ServerInfo};
|
||||||
pub use logs::{LogState, SseLayer, log_sse, log_dump, init_logging, LoggingOptions};
|
pub use logs::{LogState, SseLayer, log_sse, log_dump, init_logging, LoggingOptions, log_setup_get, log_setup_post};
|
||||||
|
|||||||
@@ -16,10 +16,18 @@ use axum::{
|
|||||||
IntoResponse,
|
IntoResponse,
|
||||||
sse::{Event, KeepAlive, Sse},
|
sse::{Event, KeepAlive, Sse},
|
||||||
},
|
},
|
||||||
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use tracing_subscriber::{Registry, layer::SubscriberExt};
|
use tracing_subscriber::{
|
||||||
|
Registry,
|
||||||
|
layer::SubscriberExt,
|
||||||
|
reload,
|
||||||
|
filter::LevelFilter,
|
||||||
|
util::SubscriberInitExt,
|
||||||
|
};
|
||||||
|
use tracing::Level;
|
||||||
|
|
||||||
/// Représente une entrée de log
|
/// Représente une entrée de log
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -35,16 +43,42 @@ pub struct LogEntry {
|
|||||||
pub struct LogState {
|
pub struct LogState {
|
||||||
buffer: Arc<RwLock<VecDeque<LogEntry>>>,
|
buffer: Arc<RwLock<VecDeque<LogEntry>>>,
|
||||||
tx: broadcast::Sender<LogEntry>,
|
tx: broadcast::Sender<LogEntry>,
|
||||||
|
max_level: Arc<RwLock<Level>>,
|
||||||
|
reload_handle: Arc<RwLock<reload::Handle<LevelFilter, Registry>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LogState {
|
impl LogState {
|
||||||
pub fn new(capacity: usize) -> Self {
|
pub fn new(capacity: usize, reload_handle: reload::Handle<LevelFilter, Registry>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
buffer: Arc::new(RwLock::new(VecDeque::with_capacity(capacity))),
|
buffer: Arc::new(RwLock::new(VecDeque::with_capacity(capacity))),
|
||||||
tx: broadcast::channel(1000).0,
|
tx: broadcast::channel(1000).0,
|
||||||
|
max_level: Arc::new(RwLock::new(Level::TRACE)),
|
||||||
|
reload_handle: Arc::new(RwLock::new(reload_handle)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_max_level(&self, level: Level) {
|
||||||
|
*self.max_level.write().unwrap() = level;
|
||||||
|
|
||||||
|
// Convertir Level en LevelFilter
|
||||||
|
let level_filter = match level {
|
||||||
|
Level::ERROR => LevelFilter::ERROR,
|
||||||
|
Level::WARN => LevelFilter::WARN,
|
||||||
|
Level::INFO => LevelFilter::INFO,
|
||||||
|
Level::DEBUG => LevelFilter::DEBUG,
|
||||||
|
Level::TRACE => LevelFilter::TRACE,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recharger le filtre dynamiquement
|
||||||
|
if let Err(e) = self.reload_handle.write().unwrap().reload(level_filter) {
|
||||||
|
tracing::error!("Failed to reload log level filter: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_max_level(&self) -> Level {
|
||||||
|
*self.max_level.read().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
fn push(&self, entry: LogEntry) {
|
fn push(&self, entry: LogEntry) {
|
||||||
let mut buf = self.buffer.write().unwrap();
|
let mut buf = self.buffer.write().unwrap();
|
||||||
if buf.len() == buf.capacity() {
|
if buf.len() == buf.capacity() {
|
||||||
@@ -195,23 +229,116 @@ impl Default for LoggingOptions {
|
|||||||
/// });
|
/// });
|
||||||
/// ```
|
/// ```
|
||||||
pub fn init_logging(options: LoggingOptions) -> LogState {
|
pub fn init_logging(options: LoggingOptions) -> LogState {
|
||||||
let log_state = LogState::new(options.buffer_capacity);
|
// Créer un filtre rechargeable qui commence à TRACE
|
||||||
|
let (filter, reload_handle) = reload::Layer::new(LevelFilter::TRACE);
|
||||||
|
|
||||||
let subscriber = Registry::default().with(SseLayer::new(log_state.clone()));
|
// Créer le LogState avec le handle de rechargement
|
||||||
|
let log_state = LogState::new(options.buffer_capacity, reload_handle);
|
||||||
|
|
||||||
|
// Construire le subscriber avec le filtre rechargeable
|
||||||
|
let subscriber = Registry::default()
|
||||||
|
.with(filter)
|
||||||
|
.with(SseLayer::new(log_state.clone()));
|
||||||
|
|
||||||
if options.enable_console {
|
if options.enable_console {
|
||||||
let subscriber = subscriber.with(
|
subscriber
|
||||||
tracing_subscriber::fmt::layer()
|
.with(
|
||||||
.with_target(true)
|
tracing_subscriber::fmt::layer()
|
||||||
.with_level(true)
|
.with_target(true)
|
||||||
.with_ansi(true),
|
.with_level(true)
|
||||||
);
|
.with_ansi(true),
|
||||||
tracing::subscriber::set_global_default(subscriber)
|
)
|
||||||
.expect("Failed to set global default subscriber");
|
.init();
|
||||||
} else {
|
} else {
|
||||||
tracing::subscriber::set_global_default(subscriber)
|
subscriber.init();
|
||||||
.expect("Failed to set global default subscriber");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log_state
|
log_state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request body pour la configuration du logging
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LogSetupRequest {
|
||||||
|
pub level: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response pour la configuration du logging
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct LogSetupResponse {
|
||||||
|
pub current_level: String,
|
||||||
|
pub available_levels: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour GET /api/log_setup - retourne la configuration actuelle
|
||||||
|
pub async fn log_setup_get(State(state): State<LogState>) -> impl IntoResponse {
|
||||||
|
let current = level_to_string(state.get_max_level());
|
||||||
|
Json(LogSetupResponse {
|
||||||
|
current_level: current,
|
||||||
|
available_levels: vec![
|
||||||
|
"ERROR".to_string(),
|
||||||
|
"WARN".to_string(),
|
||||||
|
"INFO".to_string(),
|
||||||
|
"DEBUG".to_string(),
|
||||||
|
"TRACE".to_string(),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour POST /api/log_setup - met à jour le niveau de log
|
||||||
|
pub async fn log_setup_post(
|
||||||
|
State(state): State<LogState>,
|
||||||
|
Json(payload): Json<LogSetupRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let level = match string_to_level(&payload.level) {
|
||||||
|
Some(l) => l,
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Invalid log level. Must be one of: ERROR, WARN, INFO, DEBUG, TRACE"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
state.set_max_level(level);
|
||||||
|
tracing::info!("Log level changed to: {}", payload.level);
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(LogSetupResponse {
|
||||||
|
current_level: level_to_string(level),
|
||||||
|
available_levels: vec![
|
||||||
|
"ERROR".to_string(),
|
||||||
|
"WARN".to_string(),
|
||||||
|
"INFO".to_string(),
|
||||||
|
"DEBUG".to_string(),
|
||||||
|
"TRACE".to_string(),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_to_level(s: &str) -> Option<Level> {
|
||||||
|
match s.to_uppercase().as_str() {
|
||||||
|
"ERROR" => Some(Level::ERROR),
|
||||||
|
"WARN" => Some(Level::WARN),
|
||||||
|
"INFO" => Some(Level::INFO),
|
||||||
|
"DEBUG" => Some(Level::DEBUG),
|
||||||
|
"TRACE" => Some(Level::TRACE),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn level_to_string(level: Level) -> String {
|
||||||
|
match level {
|
||||||
|
Level::ERROR => "ERROR",
|
||||||
|
Level::WARN => "WARN",
|
||||||
|
Level::INFO => "INFO",
|
||||||
|
Level::DEBUG => "DEBUG",
|
||||||
|
Level::TRACE => "TRACE",
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ where
|
|||||||
S: Subscriber,
|
S: Subscriber,
|
||||||
{
|
{
|
||||||
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
||||||
|
// Le filtrage par niveau est maintenant géré par le filtre rechargeable global
|
||||||
let mut visitor = LogVisitor::new();
|
let mut visitor = LogVisitor::new();
|
||||||
event.record(&mut visitor);
|
event.record(&mut visitor);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user