Sort webapp du pmoserver dans la crate pmoapp
This commit is contained in:
641
pmoapp/webapp/src/components/LogView.vue
Normal file
641
pmoapp/webapp/src/components/LogView.vue
Normal file
@@ -0,0 +1,641 @@
|
||||
<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>
|
||||
<select v-model="levelFilter" class="filter">
|
||||
<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>
|
||||
</select>
|
||||
</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 }]"
|
||||
>
|
||||
<span class="timestamp">{{ formatTimestamp(log.timestamp) }}</span>
|
||||
<span class="level">{{ log.level }}</span>
|
||||
<span class="target">{{ log.target }}</span>
|
||||
<span class="message markdown-content" v-html="renderMarkdown(log.message)"></span>
|
||||
</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="count">{{ filteredLogs.length }} logs</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 logContainer = ref(null)
|
||||
let eventSource = null
|
||||
let historyLoaded = false
|
||||
const seenLogIds = new Set() // Pour détecter les duplicatas
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
if (levelFilter.value === 'ALL') {
|
||||
return logs.value
|
||||
}
|
||||
return logs.value.filter(log => log.level === levelFilter.value)
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
function renderMarkdown(text) {
|
||||
// ÉTAPE 1 : Pré-processing pour détecter et protéger le XML
|
||||
let processedText = text
|
||||
|
||||
// Détecter si le message contient du XML
|
||||
// Pattern : cherche <?xml ou des balises XML racine communes (scpd, root, service, etc.)
|
||||
const hasXml = /<\?xml|<(scpd|root|service|device|actionList|stateVariable)[>\s]/i.test(text)
|
||||
|
||||
if (hasXml) {
|
||||
// Extraire tout ce qui ressemble à du XML (du <?xml ou première balise jusqu'à la fin)
|
||||
const xmlStartMatch = text.match(/<\?xml[\s\S]*$/)
|
||||
|
||||
if (xmlStartMatch) {
|
||||
const xmlContent = xmlStartMatch[0]
|
||||
const beforeXml = text.substring(0, text.indexOf(xmlContent))
|
||||
|
||||
// Créer le texte avec le XML dans un bloc de code
|
||||
processedText = beforeXml + '\n```xml\n' + xmlContent + '\n```\n'
|
||||
} else {
|
||||
// Fallback : chercher une balise racine XML
|
||||
const xmlMatch = text.match(/<([a-zA-Z][a-zA-Z0-9:-]*)[>\s][\s\S]*/)
|
||||
if (xmlMatch) {
|
||||
const xmlContent = xmlMatch[0]
|
||||
const beforeXml = text.substring(0, text.indexOf(xmlContent))
|
||||
processedText = beforeXml + '\n```xml\n' + xmlContent + '\n```\n'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ÉTAPE 2 : Convertir markdown en HTML
|
||||
const rawHtml = marked.parse(processedText, { async: false })
|
||||
|
||||
// ÉTAPE 3 : 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']
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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(() => {
|
||||
connectSSE()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
}
|
||||
})
|
||||
|
||||
// Désactiver auto-scroll si l'utilisateur scroll manuellement
|
||||
watch(logContainer, (container) => {
|
||||
if (!container) return
|
||||
|
||||
container.addEventListener('scroll', () => {
|
||||
const isAtBottom =
|
||||
container.scrollHeight - container.scrollTop <= container.clientHeight + 50
|
||||
|
||||
if (!isAtBottom && autoScroll.value) {
|
||||
autoScroll.value = false
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.log-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 80vh;
|
||||
width: 100vw;
|
||||
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;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter {
|
||||
padding: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.log-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 80px 200px 1fr;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
border-left: 3px solid transparent;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
.log-entry:hover {
|
||||
background: #2d2d30;
|
||||
}
|
||||
|
||||
.log-entry.is-history {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #858585;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.timestamp {
|
||||
font-size: 0.75rem;
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.level {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.level {
|
||||
order: 2;
|
||||
width: fit-content;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.target {
|
||||
color: #4ec9b0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.target {
|
||||
order: 3;
|
||||
font-size: 0.8rem;
|
||||
color: #6eb8a5;
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
color: #d4d4d4;
|
||||
word-break: break-word;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.message {
|
||||
order: 4;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
.level-trace {
|
||||
border-left-color: #808080;
|
||||
}
|
||||
|
||||
.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-warn {
|
||||
border-left-color: #dcdcaa;
|
||||
}
|
||||
|
||||
.level-warn .level {
|
||||
background: #4d4d2a;
|
||||
color: #dcdcaa;
|
||||
}
|
||||
|
||||
.level-error {
|
||||
border-left-color: #f48771;
|
||||
}
|
||||
|
||||
.level-error .level {
|
||||
background: #5a1e1e;
|
||||
color: #f48771;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.footer {
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
color: #f48771;
|
||||
}
|
||||
|
||||
.status.connected {
|
||||
color: #4ec9b0;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: #858585;
|
||||
}
|
||||
|
||||
/* 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>
|
||||
Reference in New Issue
Block a user