785 lines
23 KiB
Vue
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>
|