From 3c1b1a449d0ec62e078b6d08b5366347a6fa4ef8 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 10 Oct 2025 06:58:48 +0200 Subject: [PATCH] =?UTF-8?q?Am=C3=A9lioration=20du=20visualiseur=20de=20log?= =?UTF-8?q?=20web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pmoapp/webapp/src/components/LogView.vue | 325 ++++++++++++++++++----- pmoserver/src/lib.rs | 2 +- pmoserver/src/logs/mod.rs | 155 ++++++++++- pmoserver/src/logs/sselayer.rs | 1 + 4 files changed, 398 insertions(+), 85 deletions(-) diff --git a/pmoapp/webapp/src/components/LogView.vue b/pmoapp/webapp/src/components/LogView.vue index 03b1a0e0..1f28affa 100644 --- a/pmoapp/webapp/src/components/LogView.vue +++ b/pmoapp/webapp/src/components/LogView.vue @@ -7,13 +7,24 @@ {{ autoScroll ? '📌 Auto-scroll ON' : '📌 Auto-scroll OFF' }} + + + + + @@ -24,16 +35,28 @@ :key="index" :class="['log-entry', `level-${log.level.toLowerCase()}`, { 'is-history': log.isHistory }]" > - {{ formatTimestamp(log.timestamp) }} - {{ log.level }} - {{ log.target }} - +
+ {{ formatTimestamp(log.timestamp) }} + {{ log.level }} + {{ log.target }} +
+
+
+
+ + {{ truncateMessage(log.message) }} + +
+
+
+
+
- +
⏳ Loading history...
- +
{{ isConnected ? 'Waiting for logs...' : 'Connecting to log stream...' }}
@@ -43,6 +66,7 @@ {{ isConnected ? '🟢 Connected' : '🔴 Disconnected' }} + Server level: {{ serverLogLevel }} {{ filteredLogs.length }} logs @@ -64,11 +88,21 @@ const autoScroll = ref(true) const isConnected = ref(false) const isLoadingHistory = ref(true) const levelFilter = ref('ALL') +const serverLogLevel = ref('TRACE') 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 +} + const filteredLogs = computed(() => { if (levelFilter.value === 'ALL') { return logs.value @@ -76,6 +110,43 @@ const filteredLogs = computed(() => { 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) { const date = new Date(timestamp.secs_since_epoch * 1000) 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) { // ÉTAPE 1 : Pré-processing pour détecter et protéger le XML 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![Image](${match})\n` + }) + + // ÉTAPE 3 : Convertir markdown en HTML 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, { - ALLOWED_TAGS: ['strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span'], - ALLOWED_ATTR: ['href', 'target', 'class'] + ALLOWED_TAGS: ['strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span', 'img'], + ALLOWED_ATTR: ['href', 'target', 'class', 'src', 'alt', 'title'] }) } @@ -157,23 +250,23 @@ function connectSSE() { 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 } - + logs.value.push(logEntry) - + // Limiter à 1000 logs en mémoire if (logs.value.length > 1000) { 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}` seenLogIds.delete(removedId) } - + scrollToBottom() } catch (error) { console.error('Failed to parse log entry:', error) @@ -192,7 +285,7 @@ function connectSSE() { isConnected.value = false isLoadingHistory.value = false console.error('SSE connection error') - + // Reconnexion automatique après 3 secondes setTimeout(() => { if (eventSource.readyState === EventSource.CLOSED) { @@ -209,7 +302,7 @@ function connectSSE() { eventSource.onmessage = (event) => { clearTimeout(historyTimeout) originalOnMessage(event) - + if (!historyLoaded) { historyTimeout = setTimeout(() => { historyLoaded = true @@ -221,6 +314,7 @@ function connectSSE() { } onMounted(() => { + loadServerLogLevel() connectSSE() }) @@ -233,11 +327,11 @@ onUnmounted(() => { // Désactiver auto-scroll si l'utilisateur scroll manuellement watch(logContainer, (container) => { if (!container) return - + container.addEventListener('scroll', () => { - const isAtBottom = + const isAtBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 50 - + if (!isAtBottom && autoScroll.value) { autoScroll.value = false } @@ -281,7 +375,7 @@ watch(logContainer, (container) => { .header { padding: 0.75rem 1rem; } - + .header h2 { font-size: 1rem; width: 100%; @@ -338,6 +432,13 @@ button.active { 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) { @@ -357,20 +458,18 @@ button.active { } .log-entry { - display: grid; - grid-template-columns: 130px 80px 200px 1fr; - gap: 1rem; + 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 { - grid-template-columns: 1fr; - gap: 0.3rem; padding: 0.75rem 0.5rem; font-size: 0.85rem; border-left-width: 4px; @@ -385,15 +484,32 @@ button.active { 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; - order: 1; } } @@ -403,12 +519,11 @@ button.active { padding: 0.1rem 0.5rem; border-radius: 3px; text-align: center; + flex-shrink: 0; } @media (max-width: 768px) { .level { - order: 2; - width: fit-content; font-size: 0.75rem; padding: 0.2rem 0.6rem; } @@ -417,11 +532,15 @@ button.active { .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 { - order: 3; font-size: 0.8rem; color: #6eb8a5; } @@ -433,11 +552,61 @@ button.active { text-align: left; } -@media (max-width: 768px) { - .message { - order: 4; - margin-top: 0.25rem; - } +.log-details { + margin: 0; +} + +.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 { @@ -478,6 +647,16 @@ button.active { 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 */ .markdown-content :deep(pre)::-webkit-scrollbar { width: 8px; @@ -527,32 +706,14 @@ button.active { padding-left: 1.5rem; } -/* Level colors */ -.level-trace { - border-left-color: #808080; +/* Level colors - Classés par ordre de gravité */ +.level-error { + border-left-color: #f48771; } -.level-trace .level { - background: #3a3a3a; - color: #a0a0a0; -} - -.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-error .level { + background: #5a1e1e; + color: #f48771; } .level-warn { @@ -564,13 +725,31 @@ button.active { color: #dcdcaa; } -.level-error { - border-left-color: #f48771; +.level-info { + border-left-color: #4ec9b0; } -.level-error .level { - background: #5a1e1e; - color: #f48771; +.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 { @@ -600,6 +779,7 @@ button.active { background: #252526; border-top: 1px solid #3e3e42; font-size: 0.9rem; + gap: 1rem; } @media (max-width: 768px) { @@ -617,6 +797,11 @@ button.active { color: #4ec9b0; } +.server-info { + color: #569cd6; + font-weight: bold; +} + .count { color: #858585; } @@ -638,4 +823,4 @@ button.active { .log-container::-webkit-scrollbar-thumb:hover { background: #4e4e4e; } - \ No newline at end of file + diff --git a/pmoserver/src/lib.rs b/pmoserver/src/lib.rs index 221752ea..71689fa5 100644 --- a/pmoserver/src/lib.rs +++ b/pmoserver/src/lib.rs @@ -72,4 +72,4 @@ pub mod server; pub mod logs; 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}; diff --git a/pmoserver/src/logs/mod.rs b/pmoserver/src/logs/mod.rs index 1d05e274..c1e66ea6 100644 --- a/pmoserver/src/logs/mod.rs +++ b/pmoserver/src/logs/mod.rs @@ -16,10 +16,18 @@ use axum::{ IntoResponse, sse::{Event, KeepAlive, Sse}, }, + http::StatusCode, }; use serde::{Deserialize, Serialize}; 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 #[derive(Debug, Clone, Serialize)] @@ -35,16 +43,42 @@ pub struct LogEntry { pub struct LogState { buffer: Arc>>, tx: broadcast::Sender, + max_level: Arc>, + reload_handle: Arc>>, } impl LogState { - pub fn new(capacity: usize) -> Self { + pub fn new(capacity: usize, reload_handle: reload::Handle) -> Self { Self { buffer: Arc::new(RwLock::new(VecDeque::with_capacity(capacity))), 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) { let mut buf = self.buffer.write().unwrap(); if buf.len() == buf.capacity() { @@ -195,23 +229,116 @@ impl Default for LoggingOptions { /// }); /// ``` 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 { - let subscriber = subscriber.with( - tracing_subscriber::fmt::layer() - .with_target(true) - .with_level(true) - .with_ansi(true), - ); - tracing::subscriber::set_global_default(subscriber) - .expect("Failed to set global default subscriber"); + subscriber + .with( + tracing_subscriber::fmt::layer() + .with_target(true) + .with_level(true) + .with_ansi(true), + ) + .init(); } else { - tracing::subscriber::set_global_default(subscriber) - .expect("Failed to set global default subscriber"); + subscriber.init(); } 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, +} + +/// Handler pour GET /api/log_setup - retourne la configuration actuelle +pub async fn log_setup_get(State(state): State) -> 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, + Json(payload): Json, +) -> 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 { + 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() +} diff --git a/pmoserver/src/logs/sselayer.rs b/pmoserver/src/logs/sselayer.rs index 4d2ec3fa..f2407789 100644 --- a/pmoserver/src/logs/sselayer.rs +++ b/pmoserver/src/logs/sselayer.rs @@ -48,6 +48,7 @@ where S: Subscriber, { 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(); event.record(&mut visitor);