Files
adam-acs/inertia/components/InspectorPanel.vue
T
Kevin L'Hereec 0c83a4ccb2 feat: TR-069 Exchange Viewer - structured MongoDB storage with SSE streaming and sequence diagram UI
- MongoDB storage: cwmp_exchanges collection with indexes (deviceId+timestamp, deviceId+sequenceNumber unique, TTL 24h)
- ExchangeStoreService: CWMP method extraction, per-device queue for ordering, retry logic, message cap (5000/device)
- Exchange API: GET/DELETE/SSE stream endpoints with Last-Event-ID reconnection
- CwmpLoggerMiddleware: dual-write (MongoDB + legacy files) with fire-and-forget
- Frontend: SequenceViewer (CPE↔ACS lifelines, arrows, auto-scroll, virtual scroll) + InspectorPanel (formatted XML, copy, syntax highlighting)
- Tr069Trace page: integrated new viewer with col-6/col-6 layout
- DeviceDetail page: collapsible exchange section when device is traced
- 14 property-based tests, 40+ unit tests, 5 integration tests
- Health check includes index verification
- Config: feature flags, SSE timeouts, retry policy
2026-06-03 15:52:44 +02:00

349 lines
9.5 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { CwmpExchange } from '../composables/useExchangeStream'
const props = defineProps<{
selectedMessage: CwmpExchange | null
}>()
const panelWidth = ref(400)
const isResizing = ref(false)
const copyConfirmation = ref(false)
const MIN_WIDTH = 300
const MAX_WIDTH_PERCENT = 0.7
const maxWidth = computed(() => {
if (typeof window !== 'undefined') {
return Math.floor(window.innerWidth * MAX_WIDTH_PERCENT)
}
return 900
})
const panelStyle = computed(() => ({
width: '100%',
height: '100%',
}))
// XML formatting with syntax highlighting
function formatXml(xml: string): string {
if (!xml || xml.trim() === '') return ''
try {
return prettyPrintXml(xml)
} catch {
// If XML is unparseable, return raw text escaped
return escapeHtml(xml)
}
}
function prettyPrintXml(xml: string): string {
// Simple XML formatter with 2-space indentation
let formatted = ''
let indent = 0
const lines = xml
.replace(/>\s*</g, '>\n<')
.replace(/\n\s*\n/g, '\n')
.split('\n')
for (const rawLine of lines) {
const line = rawLine.trim()
if (!line) continue
// Closing tag
if (line.startsWith('</')) {
indent = Math.max(0, indent - 1)
formatted += ' '.repeat(indent * 2) + line + '\n'
}
// Self-closing tag or processing instruction
else if (line.endsWith('/>') || line.startsWith('<?')) {
formatted += ' '.repeat(indent * 2) + line + '\n'
}
// Opening tag (not self-closing)
else if (line.startsWith('<') && !line.startsWith('</') && line.includes('>')) {
formatted += ' '.repeat(indent * 2) + line + '\n'
// Only increase indent if this is a pure opening tag (not self-closing and not containing content + close)
const tagContent = line.replace(/<[^>]*>/g, '').trim()
const closeTagMatch = line.match(/<\/[^>]+>/)
if (!closeTagMatch && !line.endsWith('/>') && tagContent === '') {
indent++
}
} else {
formatted += ' '.repeat(indent * 2) + line + '\n'
}
}
return formatted.trim()
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
// Syntax highlighting for XML
function highlightXml(formattedXml: string): string {
if (!formattedXml) return ''
const escaped = escapeHtml(formattedXml)
// Highlight XML elements, attributes, and values with distinct colors
return escaped
// Processing instructions: <?xml ... ?>
.replace(
/&lt;\?([^?]*)\?&gt;/g,
'<span class="xml-processing">&lt;?$1?&gt;</span>'
)
// Closing tags: </tagName>
.replace(
/&lt;\/([a-zA-Z0-9_:\-\.]+)&gt;/g,
'<span class="xml-bracket">&lt;/</span><span class="xml-element">$1</span><span class="xml-bracket">&gt;</span>'
)
// Self-closing tags with attributes: <tag attr="val" />
.replace(
/&lt;([a-zA-Z0-9_:\-\.]+)((?:\s+[^&]*?)?)\/&gt;/g,
(_match, tag, attrs) => {
const highlightedAttrs = highlightAttributes(attrs)
return `<span class="xml-bracket">&lt;</span><span class="xml-element">${tag}</span>${highlightedAttrs}<span class="xml-bracket">/&gt;</span>`
}
)
// Opening tags with attributes: <tag attr="val">
.replace(
/&lt;([a-zA-Z0-9_:\-\.]+)((?:\s+[^&]*?)?)&gt;/g,
(_match, tag, attrs) => {
const highlightedAttrs = highlightAttributes(attrs)
return `<span class="xml-bracket">&lt;</span><span class="xml-element">${tag}</span>${highlightedAttrs}<span class="xml-bracket">&gt;</span>`
}
)
}
function highlightAttributes(attrs: string): string {
if (!attrs || attrs.trim() === '') return ''
// Highlight attribute="value" pairs
return attrs.replace(
/([a-zA-Z0-9_:\-\.]+)=&quot;([^&]*?)&quot;/g,
'<span class="xml-attribute"> $1</span>=<span class="xml-value">&quot;$2&quot;</span>'
)
}
const formattedXml = computed(() => {
if (!props.selectedMessage) return ''
return formatXml(props.selectedMessage.rawXml)
})
const highlightedXml = computed(() => {
if (!formattedXml.value) return ''
return highlightXml(formattedXml.value)
})
const hasContent = computed(() => {
if (!props.selectedMessage) return false
return props.selectedMessage.rawXml && props.selectedMessage.rawXml.trim() !== ''
})
const isUnparseable = computed(() => {
if (!props.selectedMessage) return false
if (!hasContent.value) return false
// Check if XML starts with < — if not, it's likely unparseable
const trimmed = props.selectedMessage.rawXml.trim()
return !trimmed.startsWith('<')
})
const headerText = computed(() => {
if (!props.selectedMessage) return ''
return `${props.selectedMessage.cwmpMethod} — ${props.selectedMessage.direction}`
})
// Copy to clipboard
async function copyToClipboard() {
if (!props.selectedMessage?.rawXml) return
try {
await navigator.clipboard.writeText(props.selectedMessage.rawXml)
copyConfirmation.value = true
setTimeout(() => {
copyConfirmation.value = false
}, 2000)
} catch {
// Fallback for older browsers
const textArea = document.createElement('textarea')
textArea.value = props.selectedMessage.rawXml
textArea.style.position = 'fixed'
textArea.style.opacity = '0'
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
copyConfirmation.value = true
setTimeout(() => {
copyConfirmation.value = false
}, 2000)
}
}
// Resize handling
function startResize(event: MouseEvent) {
event.preventDefault()
isResizing.value = true
document.addEventListener('mousemove', onResize)
document.addEventListener('mouseup', stopResize)
}
function onResize(event: MouseEvent) {
if (!isResizing.value) return
const newWidth = window.innerWidth - event.clientX
panelWidth.value = Math.min(Math.max(newWidth, MIN_WIDTH), maxWidth.value)
}
function stopResize() {
isResizing.value = false
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
}
// Reset copy confirmation when message changes
watch(
() => props.selectedMessage,
() => {
copyConfirmation.value = false
}
)
</script>
<template>
<div class="inspector-panel border-start" :style="panelStyle">
<!-- Resize handle -->
<div
class="inspector-resize-handle"
@mousedown="startResize"
></div>
<!-- No message selected placeholder -->
<div v-if="!selectedMessage" class="inspector-placeholder d-flex align-items-center justify-content-center h-100 text-muted">
<div class="text-center p-4">
<i class="bi bi-file-earmark-code fs-1 d-block mb-3 opacity-50"></i>
<p class="mb-0">Sélectionnez un message pour inspecter son contenu XML</p>
</div>
</div>
<!-- Message content -->
<div v-else class="d-flex flex-column h-100">
<!-- Header -->
<div class="inspector-header border-bottom px-3 py-2 d-flex align-items-center justify-content-between bg-light">
<div class="fw-bold text-truncate me-2">
{{ headerText }}
</div>
<button
class="btn btn-sm btn-outline-secondary d-flex align-items-center gap-1"
:class="{ 'btn-success': copyConfirmation }"
@click="copyToClipboard"
:disabled="!hasContent"
>
<i :class="copyConfirmation ? 'bi bi-check-lg' : 'bi bi-clipboard'"></i>
<span>{{ copyConfirmation ? 'Copié' : 'Copy' }}</span>
</button>
</div>
<!-- XML Content -->
<div class="inspector-content flex-grow-1 overflow-auto p-3">
<!-- Empty or unparseable XML -->
<div v-if="!hasContent" class="text-muted fst-italic">
Aucun contenu XML disponible pour ce message.
</div>
<!-- Unparseable XML (not starting with <) -->
<div v-else-if="isUnparseable" class="text-muted">
<p class="fst-italic mb-2">Contenu non-XML :</p>
<pre class="xml-raw mb-0"><code>{{ selectedMessage.rawXml }}</code></pre>
</div>
<!-- Formatted and highlighted XML -->
<pre v-else class="xml-highlighted mb-0"><code v-html="highlightedXml"></code></pre>
</div>
</div>
</div>
</template>
<style scoped>
.inspector-panel {
position: relative;
height: 100%;
background-color: var(--bs-body-bg, #fff);
overflow: hidden;
display: flex;
flex-direction: column;
}
.inspector-resize-handle {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: col-resize;
z-index: 10;
transition: background-color 0.2s;
}
.inspector-resize-handle:hover,
.inspector-resize-handle:active {
background-color: var(--bs-primary, #ff7900);
}
.inspector-placeholder {
color: var(--bs-secondary, #666);
}
.inspector-header {
flex-shrink: 0;
}
.inspector-content {
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 0.8125rem;
line-height: 1.5;
}
.inspector-content pre {
white-space: pre-wrap;
word-wrap: break-word;
background: transparent;
border: none;
padding: 0;
}
/* XML Syntax Highlighting */
:deep(.xml-element) {
color: #0550ae;
}
:deep(.xml-attribute) {
color: #953800;
}
:deep(.xml-value) {
color: #0a3069;
}
:deep(.xml-bracket) {
color: #24292f;
}
:deep(.xml-processing) {
color: #6e7781;
font-style: italic;
}
.xml-raw {
background-color: var(--bs-light, #f8f9fa);
border-radius: 0.25rem;
padding: 0.75rem;
font-size: 0.8125rem;
}
</style>