Files
adam-acs/inertia/composables/useExchangeStream.ts
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

291 lines
8.2 KiB
TypeScript

import { ref, onUnmounted, type Ref } from 'vue'
/**
* Represents a single CWMP exchange message received from the backend.
*/
export interface CwmpExchange {
deviceId: string
direction: 'CPE→ACS' | 'ACS→CPE'
timestamp: string
sessionId: string
cwmpMethod: string
rawXml: string
sequenceNumber: number
createdAt?: string
}
export type ConnectionState = 'connected' | 'paused' | 'disconnected'
export interface UseExchangeStreamReturn {
/** Reactive array of received exchange messages */
messages: Ref<CwmpExchange[]>
/** Current connection state */
connectionState: Ref<ConnectionState>
/** Whether auto-scroll is enabled (paused when user scrolls up) */
isAutoScrollEnabled: Ref<boolean>
/** Connect to the SSE stream for the device */
connect: () => void
/** Disconnect from the SSE stream */
disconnect: () => void
/** Resume auto-scroll and scroll to the latest message */
resumeAutoScroll: () => void
/** Clear all exchanges server-side and reset local state */
clearMessages: () => Promise<void>
}
const POLLING_INTERVAL_MS = 5000
const MAX_CACHED_MESSAGES = 1000
/**
* Composable for managing real-time CWMP exchange streaming via SSE,
* with fallback to REST polling if SSE is unavailable.
*
* Requirements: 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8, 8.4
*/
export function useExchangeStream(deviceId: string): UseExchangeStreamReturn {
const messages = ref<CwmpExchange[]>([])
const connectionState = ref<ConnectionState>('disconnected')
const isAutoScrollEnabled = ref<boolean>(true)
let eventSource: EventSource | null = null
let lastSequenceNumber: number | null = null
let pollingTimer: ReturnType<typeof setInterval> | null = null
let sseSupported = true
let reconnectAttempts = 0
const MAX_RECONNECT_ATTEMPTS = 3
/**
* Build the SSE stream URL for the device.
*/
function getStreamUrl(): string {
return `/api/v1/tr069-trace/${encodeURIComponent(deviceId)}/exchanges/stream`
}
/**
* Build the exchanges REST API URL for the device.
*/
function getExchangesUrl(after?: string): string {
const base = `/api/v1/tr069-trace/${encodeURIComponent(deviceId)}/exchanges`
if (after) {
return `${base}?after=${encodeURIComponent(after)}`
}
return base
}
/**
* Append a message to the messages array, enforcing the client-side cap.
*/
function appendMessage(exchange: CwmpExchange) {
messages.value = [...messages.value, exchange]
if (messages.value.length > MAX_CACHED_MESSAGES) {
messages.value = messages.value.slice(-MAX_CACHED_MESSAGES)
}
lastSequenceNumber = exchange.sequenceNumber
}
/**
* Connect to the SSE stream.
* If SSE fails (404, 503, or repeated connection errors), falls back to REST polling.
*
* Requirements: 6.2, 6.7, 6.8
*/
function connect() {
// Clean up any existing connection
disconnect()
reconnectAttempts = 0
if (sseSupported) {
connectSSE()
} else {
startPolling()
}
}
/**
* Establish an EventSource connection to the SSE endpoint.
*/
function connectSSE() {
const url = getStreamUrl()
eventSource = new EventSource(url)
eventSource.onopen = () => {
connectionState.value = isAutoScrollEnabled.value ? 'connected' : 'paused'
reconnectAttempts = 0
}
eventSource.onmessage = (event: MessageEvent) => {
try {
const exchange: CwmpExchange = JSON.parse(event.data)
appendMessage(exchange)
// Update connection state based on auto-scroll
connectionState.value = isAutoScrollEnabled.value ? 'connected' : 'paused'
} catch {
// Ignore parse errors for malformed SSE data
}
}
eventSource.onerror = () => {
reconnectAttempts++
// EventSource automatically reconnects using the retry interval (3s) set by the server.
// It also sends the Last-Event-ID header automatically on reconnection (Requirement 6.8).
// After MAX_RECONNECT_ATTEMPTS consecutive errors, fall back to REST polling.
if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
// SSE seems unavailable — fall back to REST polling
sseSupported = false
closeEventSource()
connectionState.value = 'disconnected'
startPolling()
} else {
connectionState.value = 'disconnected'
}
}
}
/**
* Close the EventSource connection without triggering a fallback.
*/
function closeEventSource() {
if (eventSource) {
eventSource.close()
eventSource = null
}
}
/**
* Disconnect from the SSE stream and stop any polling.
* Preserves all previously displayed messages (Requirement 6.7).
*/
function disconnect() {
closeEventSource()
stopPolling()
connectionState.value = 'disconnected'
}
/**
* Start REST polling as a fallback when SSE is unavailable.
* Polls every 5 seconds for new messages using the `after` timestamp parameter.
*/
function startPolling() {
stopPolling()
connectionState.value = isAutoScrollEnabled.value ? 'connected' : 'paused'
pollingTimer = setInterval(async () => {
await pollExchanges()
}, POLLING_INTERVAL_MS)
// Do an immediate poll
void pollExchanges()
}
/**
* Stop REST polling.
*/
function stopPolling() {
if (pollingTimer !== null) {
clearInterval(pollingTimer)
pollingTimer = null
}
}
/**
* Fetch new exchanges from the REST API using the `after` parameter
* based on the last received message timestamp.
*/
async function pollExchanges() {
try {
const lastMessage = messages.value.length > 0 ? messages.value[messages.value.length - 1] : null
const after = lastMessage?.timestamp
const url = getExchangesUrl(after)
const response = await fetch(url)
if (!response.ok) {
if (response.status === 404 || response.status === 503) {
// Device not traced or service unavailable
connectionState.value = 'disconnected'
stopPolling()
return
}
return
}
const data = await response.json()
const exchanges: CwmpExchange[] = data.exchanges || []
for (const exchange of exchanges) {
// Avoid duplicates by checking sequence number
if (lastSequenceNumber === null || exchange.sequenceNumber > lastSequenceNumber) {
appendMessage(exchange)
}
}
connectionState.value = isAutoScrollEnabled.value ? 'connected' : 'paused'
} catch {
// Network error during polling — keep trying
connectionState.value = 'disconnected'
}
}
/**
* Resume auto-scroll and update connection state.
* Requirement 6.6: button allows user to resume auto-scroll.
*/
function resumeAutoScroll() {
isAutoScrollEnabled.value = true
if (eventSource || pollingTimer) {
connectionState.value = 'connected'
}
}
/**
* Clear all exchanges on the server and reset local state.
* Calls DELETE API and resets messages array.
* After clearing, verifies SSE connection is still active and reconnects if needed.
*
* Requirement 8.4: verify SSE connection remains active,
* reset to empty state, continue receiving new messages.
*/
async function clearMessages(): Promise<void> {
const url = `/api/v1/tr069-trace/${encodeURIComponent(deviceId)}/exchanges`
const response = await fetch(url, { method: 'DELETE' })
if (!response.ok && response.status !== 204) {
throw new Error(`Failed to clear exchanges: ${response.status} ${response.statusText}`)
}
// Reset local state
messages.value = []
lastSequenceNumber = null
// Verify SSE connection is still active; reconnect if needed (Requirement 8.4)
if (eventSource) {
if (eventSource.readyState === EventSource.CLOSED) {
connectSSE()
}
// If still open, continue receiving — nothing else needed
} else if (pollingTimer) {
// Polling is still running, continue as normal
} else {
// No active connection — attempt reconnection
connect()
}
}
// Cleanup on component unmount (Requirement 6.10)
onUnmounted(() => {
disconnect()
})
return {
messages,
connectionState,
isAutoScrollEnabled,
connect,
disconnect,
resumeAutoScroll,
clearMessages,
}
}