351 lines
9.5 KiB
Vue
351 lines
9.5 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import type { CwmpExchange } from '../composables/useExchangeStream'
|
|
|
|
const props = defineProps<{
|
|
selectedMessage: CwmpExchange | null
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
}
|
|
|
|
// 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(
|
|
/<\?([^?]*)\?>/g,
|
|
'<span class="xml-processing"><?$1?></span>'
|
|
)
|
|
// Closing tags: </tagName>
|
|
.replace(
|
|
/<\/([a-zA-Z0-9_:\-\.]+)>/g,
|
|
'<span class="xml-bracket"></</span><span class="xml-element">$1</span><span class="xml-bracket">></span>'
|
|
)
|
|
// Self-closing tags with attributes: <tag attr="val" />
|
|
.replace(
|
|
/<([a-zA-Z0-9_:\-\.]+)((?:\s+[^&]*?)?)\/>/g,
|
|
(_match, tag, attrs) => {
|
|
const highlightedAttrs = highlightAttributes(attrs)
|
|
return `<span class="xml-bracket"><</span><span class="xml-element">${tag}</span>${highlightedAttrs}<span class="xml-bracket">/></span>`
|
|
}
|
|
)
|
|
// Opening tags with attributes: <tag attr="val">
|
|
.replace(
|
|
/<([a-zA-Z0-9_:\-\.]+)((?:\s+[^&]*?)?)>/g,
|
|
(_match, tag, attrs) => {
|
|
const highlightedAttrs = highlightAttributes(attrs)
|
|
return `<span class="xml-bracket"><</span><span class="xml-element">${tag}</span>${highlightedAttrs}<span class="xml-bracket">></span>`
|
|
}
|
|
)
|
|
}
|
|
|
|
function highlightAttributes(attrs: string): string {
|
|
if (!attrs || attrs.trim() === '') return ''
|
|
|
|
// Highlight attribute="value" pairs
|
|
return attrs.replace(
|
|
/([a-zA-Z0-9_:\-\.]+)="([^&]*?)"/g,
|
|
'<span class="xml-attribute"> $1</span>=<span class="xml-value">"$2"</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">{{ t('inspector.selectMessage') }}</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>
|