Files
adam-acs/inertia/components/SequenceViewer.vue
T
L HEREEC Kevin INNOV/PTFM 645e845b95
Build & Deploy / build (push) Failing after 35s
Build & Deploy / docker (push) Has been skipped
Build & Deploy / deploy (push) Has been skipped
fix: add /internal routes protected by session for Vue frontend fetches, replace /api/v1/ calls in all Vue components
2026-07-13 14:59:01 +02:00

785 lines
23 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatTimestamp, truncateMethod, needsTruncation, formatTraceStartTime } from '../helpers/sequence_viewer_helpers'
const { t } = useI18n()
export interface CwmpExchange {
sequenceNumber: number
timestamp: string
direction: 'CPE→ACS' | 'ACS→CPE'
cwmpMethod: string
sessionId: string
rawXml: string
}
export interface SequenceViewerProps {
deviceId: string
device: {
identifier: string
manufacturer: string
productClass: string
serialNumber: string
}
traceStartedAt: string
compact?: boolean
}
const props = withDefaults(defineProps<SequenceViewerProps>(), {
compact: false,
})
const emit = defineEmits<{
(e: 'select', message: CwmpExchange | null): void
}>()
// --- Constants ---
const BATCH_SIZE = 50
// --- State ---
const messages = ref<CwmpExchange[]>([])
const connectionState = ref<'connected' | 'paused' | 'disconnected'>('disconnected')
const isAutoScrollEnabled = ref(true)
const selectedMessage = ref<CwmpExchange | null>(null)
const showClearConfirm = ref(false)
const clearError = ref('')
const isClearing = ref(false)
// Lazy loading state
const isLoadingOlder = ref(false)
const hasMoreOlderMessages = ref(true)
const totalMessages = ref(0)
// Scroll container ref
const scrollContainer = ref<HTMLElement | null>(null)
// Sentinel element at top for IntersectionObserver
const topSentinel = ref<HTMLElement | null>(null)
// IntersectionObserver instance
let intersectionObserver: IntersectionObserver | null = null
// --- SSE Connection ---
let eventSource: EventSource | null = null
let lastEventId: string | null = null
function connect() {
if (eventSource) {
eventSource.close()
}
const url = `/internal/tr069-trace/${encodeURIComponent(props.deviceId)}/exchanges/stream`
eventSource = new EventSource(url)
eventSource.onopen = () => {
connectionState.value = isAutoScrollEnabled.value ? 'connected' : 'paused'
}
eventSource.onmessage = (event) => {
try {
const exchange: CwmpExchange = JSON.parse(event.data)
lastEventId = event.lastEventId || String(exchange.sequenceNumber)
messages.value.push(exchange)
if (isAutoScrollEnabled.value) {
nextTick(() => scrollToBottom())
}
} catch {
// Ignore parse errors
}
}
eventSource.onerror = () => {
connectionState.value = 'disconnected'
// EventSource handles reconnection automatically (3s retry)
}
}
function disconnect() {
if (eventSource) {
eventSource.close()
eventSource = null
}
connectionState.value = 'disconnected'
}
// --- Initial data fetch (latest BATCH_SIZE messages) ---
async function fetchInitialExchanges() {
try {
const response = await fetch(
`/internal/tr069-trace/${encodeURIComponent(props.deviceId)}/exchanges?limit=${BATCH_SIZE}&latest=true`
)
if (response.ok) {
const data = await response.json()
messages.value = data.exchanges || []
totalMessages.value = data.metadata?.total ?? 0
// Determine if there are older messages to load
hasMoreOlderMessages.value = messages.value.length < totalMessages.value
if (messages.value.length > 0) {
lastEventId = String(messages.value[messages.value.length - 1].sequenceNumber)
}
nextTick(() => scrollToBottom())
}
} catch {
// Silently handle — SSE will provide messages
}
}
// --- Lazy loading: load older messages in batches of 50 ---
async function loadOlderMessages() {
if (isLoadingOlder.value || !hasMoreOlderMessages.value || messages.value.length === 0) return
isLoadingOlder.value = true
try {
// Get the timestamp of the oldest currently loaded message
const oldestTimestamp = messages.value[0].timestamp
const response = await fetch(
`/internal/tr069-trace/${encodeURIComponent(props.deviceId)}/exchanges?limit=${BATCH_SIZE}&before=${encodeURIComponent(oldestTimestamp)}`
)
if (response.ok) {
const data = await response.json()
const olderBatch: CwmpExchange[] = data.exchanges || []
if (olderBatch.length === 0) {
hasMoreOlderMessages.value = false
} else {
// Preserve scroll position when prepending older messages
const container = scrollContainer.value
const previousScrollHeight = container?.scrollHeight ?? 0
// Prepend older messages (they're already in ascending order)
messages.value = [...olderBatch, ...messages.value]
// If we received fewer than BATCH_SIZE, there are no more older messages
if (olderBatch.length < BATCH_SIZE) {
hasMoreOlderMessages.value = false
}
// Restore scroll position after DOM update
nextTick(() => {
if (container) {
const newScrollHeight = container.scrollHeight
container.scrollTop = newScrollHeight - previousScrollHeight
}
})
}
}
} catch {
// Silently handle fetch errors
} finally {
isLoadingOlder.value = false
}
}
// --- IntersectionObserver for lazy loading older messages ---
function setupIntersectionObserver() {
if (intersectionObserver) {
intersectionObserver.disconnect()
}
intersectionObserver = new IntersectionObserver(
(entries) => {
const entry = entries[0]
if (entry && entry.isIntersecting && hasMoreOlderMessages.value && !isLoadingOlder.value) {
loadOlderMessages()
}
},
{
root: scrollContainer.value,
rootMargin: '100px 0px 0px 0px', // Trigger 100px before reaching the top
threshold: 0,
}
)
if (topSentinel.value) {
intersectionObserver.observe(topSentinel.value)
}
}
function teardownIntersectionObserver() {
if (intersectionObserver) {
intersectionObserver.disconnect()
intersectionObserver = null
}
}
// --- Auto-scroll management ---
function handleScroll() {
if (!scrollContainer.value) return
const { scrollTop, scrollHeight, clientHeight } = scrollContainer.value
const isAtBottom = scrollHeight - scrollTop - clientHeight < 60
if (isAtBottom) {
if (!isAutoScrollEnabled.value) {
isAutoScrollEnabled.value = true
if (connectionState.value === 'paused') {
connectionState.value = 'connected'
}
}
} else {
if (isAutoScrollEnabled.value) {
isAutoScrollEnabled.value = false
if (connectionState.value === 'connected') {
connectionState.value = 'paused'
}
}
}
}
function resumeAutoScroll() {
isAutoScrollEnabled.value = true
if (connectionState.value === 'paused') {
connectionState.value = 'connected'
}
scrollToBottom()
}
function scrollToBottom() {
if (!scrollContainer.value) return
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight
}
// --- Message selection ---
function selectMessage(message: CwmpExchange) {
if (selectedMessage.value?.sequenceNumber === message.sequenceNumber) {
selectedMessage.value = null
emit('select', null)
} else {
selectedMessage.value = message
emit('select', message)
}
}
// --- Clear exchanges ---
function requestClear() {
showClearConfirm.value = true
clearError.value = ''
}
function cancelClear() {
showClearConfirm.value = false
clearError.value = ''
}
async function confirmClear() {
isClearing.value = true
clearError.value = ''
try {
const response = await fetch(
`/internal/tr069-trace/${encodeURIComponent(props.deviceId)}/exchanges`,
{ method: 'DELETE' }
)
if (response.status === 204 || response.ok) {
messages.value = []
selectedMessage.value = null
emit('select', null)
lastEventId = null
showClearConfirm.value = false
hasMoreOlderMessages.value = false
totalMessages.value = 0
// Verify SSE connection is still active
if (connectionState.value === 'disconnected') {
connect()
}
} else {
clearError.value = 'Échec de la suppression des échanges. Veuillez réessayer.'
}
} catch {
clearError.value = 'Erreur réseau lors de la suppression.'
} finally {
isClearing.value = false
}
}
// --- Computed ---
const hasMessages = computed(() => messages.value.length > 0)
// Group messages by sessionId for visual session boundaries
const sessionGroups = computed(() => {
const groups: Array<{ sessionId: string; messages: CwmpExchange[]; firstMethod: string }> = []
let currentGroup: { sessionId: string; messages: CwmpExchange[]; firstMethod: string } | null = null
for (const msg of messages.value) {
if (!currentGroup || currentGroup.sessionId !== msg.sessionId) {
currentGroup = { sessionId: msg.sessionId, messages: [], firstMethod: msg.cwmpMethod }
groups.push(currentGroup)
}
currentGroup.messages.push(msg)
}
return groups
})
// Scroll to a specific session group
function scrollToSession(groupIndex: number) {
const el = document.getElementById(`session-group-${groupIndex}`)
if (el && scrollContainer.value) {
const containerTop = scrollContainer.value.getBoundingClientRect().top
const elTop = el.getBoundingClientRect().top
scrollContainer.value.scrollTop += (elTop - containerTop)
}
}
const connectionBadgeClass = computed(() => {
switch (connectionState.value) {
case 'connected':
return 'bg-success'
case 'paused':
return 'bg-warning text-dark'
case 'disconnected':
return 'bg-danger'
default:
return 'bg-secondary'
}
})
const connectionLabel = computed(() => {
switch (connectionState.value) {
case 'connected':
return t('sequence.connected')
case 'paused':
return t('sequence.paused')
case 'disconnected':
return t('sequence.disconnected')
default:
return ''
}
})
// --- Lifecycle ---
onMounted(async () => {
await fetchInitialExchanges()
connect()
// Setup IntersectionObserver after initial render
nextTick(() => {
setupIntersectionObserver()
})
})
onBeforeUnmount(() => {
disconnect()
teardownIntersectionObserver()
})
// Watch for deviceId changes
watch(
() => props.deviceId,
async () => {
disconnect()
teardownIntersectionObserver()
messages.value = []
selectedMessage.value = null
emit('select', null)
lastEventId = null
hasMoreOlderMessages.value = true
totalMessages.value = 0
await fetchInitialExchanges()
connect()
nextTick(() => {
setupIntersectionObserver()
})
}
)
</script>
<template>
<!-- Session Header (hidden in compact mode) -->
<div v-if="!compact" class="card border-0 shadow-sm mb-3">
<div class="card-body py-3 px-4">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-3">
<div>
<span class="text-muted fw-bold text-uppercase" style="font-size: 0.62rem; letter-spacing: 0.08em">
Échanges TR-069
</span>
<div class="d-flex align-items-center gap-2 mt-1">
<span class="fw-semibold small">{{ device.manufacturer }}</span>
<span class="text-muted small">{{ device.productClass }}</span>
<span class="font-monospace small text-muted">{{ device.serialNumber }}</span>
</div>
</div>
<div class="vr mx-2" style="height: 30px"></div>
<div>
<span class="text-muted fw-bold text-uppercase" style="font-size: 0.58rem; letter-spacing: 0.06em">
Trace démarrée
</span>
<div class="font-monospace small">{{ formatTraceStartTime(traceStartedAt) }}</div>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge" :class="connectionBadgeClass">
{{ connectionLabel }}
</span>
<button
v-if="hasMessages"
class="btn btn-outline-danger btn-sm"
@click="requestClear"
:disabled="isClearing"
>
{{ t('sequence.clearExchanges') }}
</button>
</div>
</div>
</div>
</div>
<!-- Compact toolbar (shown in compact mode) -->
<div v-if="compact" class="d-flex align-items-center justify-content-between px-2 py-1 border-bottom bg-light">
<span class="badge" :class="connectionBadgeClass" style="font-size: 0.65rem">
{{ connectionLabel }}
</span>
<button
v-if="hasMessages"
class="btn btn-outline-danger py-0 px-2"
style="font-size: 0.65rem"
@click="requestClear"
:disabled="isClearing"
>
Effacer
</button>
</div>
<!-- Empty state (no lifelines when empty) -->
<div v-if="!hasMessages" class="card border-0 shadow-sm">
<div class="card-body py-5 text-center">
<div class="text-muted mb-2">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" viewBox="0 0 16 16" class="opacity-25">
<path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
<path d="M6 11.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5m-2-3a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5m-2-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5"/>
</svg>
</div>
<p class="text-muted mb-0">
{{ t('sequence.noExchanges') }}
</p>
</div>
</div>
<!-- Sequence Diagram -->
<div v-else class="card border-0 shadow-sm">
<div class="card-body p-0">
<!-- Resume auto-scroll button -->
<div v-if="!isAutoScrollEnabled" class="sticky-top bg-white border-bottom px-3 py-2 text-center" style="z-index: 10">
<button class="btn btn-sm btn-outline-primary" @click="resumeAutoScroll">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 1a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L7.5 13.293V1.5A.5.5 0 0 1 8 1"/>
</svg>
{{ t('sequence.resumeScroll') }}
</button>
</div>
<!-- Session dropdown (clickable to jump) -->
<div v-if="sessionGroups.length > 1" class="d-flex align-items-center gap-2 px-3 py-2 border-bottom bg-light">
<label class="text-muted small fw-semibold text-nowrap mb-0" style="font-size: 0.7rem">Session :</label>
<select
class="form-select form-select-sm"
style="font-size: 0.72rem; max-width: 320px"
@change="scrollToSession(Number(($event.target as HTMLSelectElement).value))"
>
<option value="" disabled selected>Aller à…</option>
<option
v-for="(group, idx) in sessionGroups"
:key="'nav-' + idx"
:value="idx"
>
S{{ idx + 1 }} — {{ group.firstMethod }} ({{ group.messages.length }} msg)
</option>
</select>
<span class="text-muted small text-nowrap" style="font-size: 0.7rem">{{ sessionGroups.length }} sessions</span>
</div>
<!-- Lifeline headers -->
<div class="d-flex align-items-center border-bottom px-3 py-2 bg-light">
<div class="text-center fw-bold small text-nowrap" style="width: 36px">{{ t('sequence.cpe') }}</div>
<div class="flex-grow-1"></div>
<div class="text-center fw-bold small text-nowrap" style="width: 36px">{{ t('sequence.acs') }}</div>
</div>
<!-- Messages container with scroll -->
<div
ref="scrollContainer"
class="sequence-messages"
style="max-height: 500px; overflow-y: auto"
@scroll="handleScroll"
>
<!-- Top sentinel for IntersectionObserver (triggers older message loading) -->
<div ref="topSentinel" class="top-sentinel">
<div v-if="isLoadingOlder" class="text-center py-2">
<div class="spinner-border spinner-border-sm text-muted" role="status">
<span class="visually-hidden">Chargement des messages plus anciens…</span>
</div>
<span class="text-muted small ms-2">{{ t('sequence.loading') }}</span>
</div>
<div v-else-if="!hasMoreOlderMessages && messages.length > BATCH_SIZE" class="text-center py-2">
<span class="text-muted small">{{ t('sequence.allLoaded') }}</span>
</div>
</div>
<div
v-for="(group, groupIndex) in sessionGroups"
:key="group.sessionId + '-' + groupIndex"
:id="'session-group-' + groupIndex"
class="session-group"
>
<!-- Session label -->
<div class="session-label d-flex align-items-center px-3 py-1">
<span class="session-badge font-monospace">Session {{ groupIndex + 1 }}</span>
<span class="session-id text-muted font-monospace ms-2">{{ group.sessionId.split('->')[0]?.split(':').pop() || '' }}</span>
</div>
<div
v-for="message in group.messages"
:key="message.sequenceNumber"
class="sequence-row d-flex align-items-center px-3 py-2"
:class="{
'sequence-row--selected': selectedMessage?.sequenceNumber === message.sequenceNumber,
'sequence-row--cpe-to-acs': message.direction === 'CPE→ACS',
'sequence-row--acs-to-cpe': message.direction === 'ACS→CPE',
}"
role="button"
tabindex="0"
@click="selectMessage(message)"
@keydown.enter="selectMessage(message)"
@keydown.space.prevent="selectMessage(message)"
>
<!-- CPE lifeline -->
<div class="lifeline-column text-center" style="width: 36px">
<div class="lifeline-bar"></div>
</div>
<!-- Arrow area -->
<div class="flex-grow-1 d-flex flex-column align-items-center position-relative">
<!-- Method label -->
<span
v-if="message.cwmpMethod === 'Empty'"
class="small text-muted fst-italic mb-1"
>
{{ t('sequence.emptyPost') }}
</span>
<span
v-else
class="small fw-medium mb-1"
:title="needsTruncation(message.cwmpMethod) ? message.cwmpMethod : undefined"
>
{{ truncateMethod(message.cwmpMethod) }}
</span>
<!-- Arrow -->
<div class="sequence-arrow w-100 position-relative" :class="[
message.direction === 'CPE→ACS' ? 'arrow-right' : 'arrow-left',
message.cwmpMethod === 'Empty' ? 'arrow-empty' : ''
]">
<div class="arrow-line"></div>
<div class="arrow-head"></div>
</div>
<!-- Timestamp -->
<span class="font-monospace text-muted mt-1" style="font-size: 0.7rem">
{{ formatTimestamp(message.timestamp) }}
</span>
</div>
<!-- ACS lifeline -->
<div class="lifeline-column text-center" style="width: 36px">
<div class="lifeline-bar"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Clear confirmation dialog -->
<Teleport to="body">
<div v-if="showClearConfirm" class="modal-backdrop fade show"></div>
<div
v-if="showClearConfirm"
class="modal fade show d-block"
tabindex="-1"
role="dialog"
aria-labelledby="clearConfirmLabel"
aria-modal="true"
>
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 id="clearConfirmLabel" class="modal-title">{{ t('sequence.clearExchanges') }}</h5>
<button type="button" class="btn-close" aria-label="Fermer" @click="cancelClear"></button>
</div>
<div class="modal-body">
<p>
Êtes-vous sûr de vouloir supprimer tous les échanges enregistrés pour cet équipement ?
Cette action est irréversible.
</p>
<div v-if="clearError" class="alert alert-danger small mb-0">{{ clearError }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-sm" @click="cancelClear">
Annuler
</button>
<button
type="button"
class="btn btn-danger btn-sm"
:disabled="isClearing"
@click="confirmClear"
>
{{ isClearing ? 'Suppression…' : 'Confirmer la suppression' }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.sequence-row {
cursor: pointer;
transition: background-color 0.15s ease;
}
.sequence-row:hover {
background-color: var(--bs-light, #f8f9fa);
}
.sequence-row--selected {
background-color: #e7f1ff !important;
outline: 2px solid #0d6efd;
outline-offset: -2px;
}
.sequence-row--cpe-to-acs .arrow-line {
background-color: #ff7900;
}
.sequence-row--cpe-to-acs .arrow-head {
border-left-color: #ff7900;
}
.sequence-row--selected.sequence-row--cpe-to-acs .arrow-line {
background-color: #ff7900;
}
.sequence-row--selected.sequence-row--cpe-to-acs .arrow-head {
border-left-color: #ff7900;
}
.sequence-row--acs-to-cpe .arrow-line {
background-color: var(--bs-info, #527edb);
}
.sequence-row--acs-to-cpe .arrow-head {
border-right-color: var(--bs-info, #527edb);
}
.lifeline-column {
position: relative;
}
.lifeline-bar {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 2px;
background-color: var(--bs-border-color, #dee2e6);
}
.sequence-arrow {
display: flex;
align-items: center;
height: 2px;
position: relative;
}
.sequence-arrow .arrow-line {
flex-grow: 1;
height: 2px;
}
.sequence-arrow .arrow-head {
width: 0;
height: 0;
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
}
.sequence-arrow.arrow-right {
flex-direction: row;
}
.sequence-arrow.arrow-right .arrow-head {
border-left: 8px solid;
border-left-color: inherit;
}
.sequence-arrow.arrow-left {
flex-direction: row-reverse;
}
.sequence-arrow.arrow-left .arrow-head {
border-right: 8px solid;
border-right-color: inherit;
}
/* Empty POST: dashed gray arrow */
.sequence-arrow.arrow-empty .arrow-line {
background: none;
border-top: 2px dashed #adb5bd;
height: 0;
}
.sequence-arrow.arrow-empty .arrow-head {
border-left-color: #adb5bd;
border-right-color: #adb5bd;
}
.sequence-messages {
scrollbar-width: thin;
}
.top-sentinel {
min-height: 1px;
}
.session-group {
border-left: 3px solid #e9ecef;
margin: 8px 4px;
border-radius: 6px;
background: #fcfcfd;
box-shadow: 0 0 0 1px #e9ecef;
overflow: hidden;
}
.session-group:nth-child(odd) {
border-left-color: #ff7900;
}
.session-group:nth-child(even) {
border-left-color: #527edb;
}
.session-label {
background: #f1f3f5;
border-bottom: 1px solid #e9ecef;
}
.session-badge {
font-size: 0.62rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #495057;
}
.session-id {
font-size: 0.6rem;
}
</style>