From 0c83a4ccb29880a9b2467cc9ed28e9e225ec5ef0 Mon Sep 17 00:00:00 2001 From: Kevin L'Hereec Date: Wed, 3 Jun 2026 15:52:44 +0200 Subject: [PATCH] feat: TR-069 Exchange Viewer - structured MongoDB storage with SSE streaming and sequence diagram UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../specs/tr069-exchange-viewer/.config.kiro | 1 + .kiro/specs/tr069-exchange-viewer/design.md | 1020 ++++++++++ .../tr069-exchange-viewer/requirements.md | 138 ++ .kiro/specs/tr069-exchange-viewer/tasks.md | 531 ++++++ app/controllers/exchange_controller.ts | 381 ++++ app/controllers/health_controller.ts | 112 +- app/middleware/cwmp_logger_middleware.ts | 37 +- app/models/exchange.ts | 202 ++ app/services/configuration_engine.ts | 2 +- app/services/exchange_index_checker.ts | 87 + app/services/exchange_metrics_service.ts | 222 +++ app/services/exchange_store_service.ts | 370 ++++ config/exchange_store.ts | 98 + .../011_create_cwmp_exchanges_collection.ts | 33 + database/seeders/cwmp_exchanges_seeder.ts | 552 ++++++ docs/api/tr069-exchanges.yaml | 475 +++++ docs/deployment/tr069-exchange-viewer.md | 363 ++++ docs/migration/tr069-exchange-viewer.md | 592 ++++++ docs/monitoring/alerting-rules.md | 396 ++++ docs/tr06_sequence/DESIGN.md | 180 ++ docs/tr06_sequence/code.html | 367 ++++ docs/tr06_sequence/screen.png | Bin 0 -> 261228 bytes docs/troubleshooting/tr069-exchange-viewer.md | 486 +++++ inertia/components/InspectorPanel.vue | 348 ++++ inertia/components/SequenceViewer.vue | 679 +++++++ inertia/composables/useExchangeStream.ts | 290 +++ inertia/helpers/sequence_viewer_helpers.ts | 51 + inertia/pages/DeviceDetail.vue | 87 +- inertia/pages/Tr069Trace.vue | 64 +- providers/mongodb_provider.ts | 9 + start/env.ts | 14 + start/routes.ts | 7 + tests/integration/clear_exchanges.spec.ts | 508 +++++ tests/integration/exchange_lifecycle.spec.ts | 699 +++++++ tests/integration/message_cap.spec.ts | 338 ++++ tests/integration/sse_reconnection.spec.ts | 452 +++++ tests/integration/ttl_expiration.spec.ts | 124 ++ .../inspector_panel.property.spec.ts | 370 ++++ .../sequence_viewer.property.spec.ts | 599 ++++++ tests/unit/components/virtual_scroll.spec.ts | 201 ++ .../use_exchange_stream_memory.spec.ts | 145 ++ .../exchange_controller.property.spec.ts | 1679 +++++++++++++++++ .../controllers/exchange_controller.spec.ts | 388 ++++ .../exchange_controller_stream.spec.ts | 369 ++++ .../controllers/health_controller.spec.ts | 295 +++ .../services/exchange_index_checker.spec.ts | 145 ++ .../services/exchange_metrics_service.spec.ts | 269 +++ .../services/exchange_store.property.spec.ts | 434 +++++ .../services/exchange_store_retry.spec.ts | 300 +++ .../services/exchange_store_service.spec.ts | 265 +++ 50 files changed, 15731 insertions(+), 43 deletions(-) create mode 100644 .kiro/specs/tr069-exchange-viewer/.config.kiro create mode 100644 .kiro/specs/tr069-exchange-viewer/design.md create mode 100644 .kiro/specs/tr069-exchange-viewer/requirements.md create mode 100644 .kiro/specs/tr069-exchange-viewer/tasks.md create mode 100644 app/controllers/exchange_controller.ts create mode 100644 app/models/exchange.ts create mode 100644 app/services/exchange_index_checker.ts create mode 100644 app/services/exchange_metrics_service.ts create mode 100644 app/services/exchange_store_service.ts create mode 100644 config/exchange_store.ts create mode 100644 database/mongo_migrations/011_create_cwmp_exchanges_collection.ts create mode 100644 database/seeders/cwmp_exchanges_seeder.ts create mode 100644 docs/api/tr069-exchanges.yaml create mode 100644 docs/deployment/tr069-exchange-viewer.md create mode 100644 docs/migration/tr069-exchange-viewer.md create mode 100644 docs/monitoring/alerting-rules.md create mode 100644 docs/tr06_sequence/DESIGN.md create mode 100644 docs/tr06_sequence/code.html create mode 100644 docs/tr06_sequence/screen.png create mode 100644 docs/troubleshooting/tr069-exchange-viewer.md create mode 100644 inertia/components/InspectorPanel.vue create mode 100644 inertia/components/SequenceViewer.vue create mode 100644 inertia/composables/useExchangeStream.ts create mode 100644 inertia/helpers/sequence_viewer_helpers.ts create mode 100644 tests/integration/clear_exchanges.spec.ts create mode 100644 tests/integration/exchange_lifecycle.spec.ts create mode 100644 tests/integration/message_cap.spec.ts create mode 100644 tests/integration/sse_reconnection.spec.ts create mode 100644 tests/integration/ttl_expiration.spec.ts create mode 100644 tests/unit/components/inspector_panel.property.spec.ts create mode 100644 tests/unit/components/sequence_viewer.property.spec.ts create mode 100644 tests/unit/components/virtual_scroll.spec.ts create mode 100644 tests/unit/composables/use_exchange_stream_memory.spec.ts create mode 100644 tests/unit/controllers/exchange_controller.property.spec.ts create mode 100644 tests/unit/controllers/exchange_controller.spec.ts create mode 100644 tests/unit/controllers/exchange_controller_stream.spec.ts create mode 100644 tests/unit/controllers/health_controller.spec.ts create mode 100644 tests/unit/services/exchange_index_checker.spec.ts create mode 100644 tests/unit/services/exchange_metrics_service.spec.ts create mode 100644 tests/unit/services/exchange_store.property.spec.ts create mode 100644 tests/unit/services/exchange_store_retry.spec.ts create mode 100644 tests/unit/services/exchange_store_service.spec.ts diff --git a/.kiro/specs/tr069-exchange-viewer/.config.kiro b/.kiro/specs/tr069-exchange-viewer/.config.kiro new file mode 100644 index 0000000..b121a78 --- /dev/null +++ b/.kiro/specs/tr069-exchange-viewer/.config.kiro @@ -0,0 +1 @@ +{"specId": "ddeb1b10-5ee4-43c9-893e-b2eba9d9c76f", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/tr069-exchange-viewer/design.md b/.kiro/specs/tr069-exchange-viewer/design.md new file mode 100644 index 0000000..a4883a4 --- /dev/null +++ b/.kiro/specs/tr069-exchange-viewer/design.md @@ -0,0 +1,1020 @@ +# Design Document: TR-069 Exchange Viewer + +## Overview + +Ce design décrit l'architecture technique du système de visualisation interactive des échanges TR-069/CWMP. Il remplace le système actuel de logs fichiers (`Tr069TraceService` + `CwmpLoggerMiddleware`) par un stockage structuré en MongoDB avec diffusion temps réel via Server-Sent Events (SSE), et un frontend affichant un diagramme de séquence CPE ↔ ACS interactif. + +**Décisions architecturales clés :** + +1. **In-process EventEmitter** pour la communication middleware → SSE (pas de MongoDB change streams, trop complexe pour un seul serveur) +2. **MongoDB collection `cwmp_exchanges`** avec index composé (deviceId, timestamp) et TTL 24h +3. **Nouveau service `ExchangeStoreService`** qui encapsule la persistance + émission d'événements, remplaçant les écritures fichier +4. **SSE via AdonisJS response streaming** avec gestion des timeouts et reconnexion Last-Event-ID +5. **Vue 3 composables** (`useExchangeStream`, `useSequenceViewer`) pour la gestion SSE côté client + +## Architecture + +```mermaid +graph TB + subgraph "Backend (AdonisJS)" + CWM[CwmpLoggerMiddleware] -->|capture SOAP| ESS[ExchangeStoreService] + ESS -->|persist| DB[(MongoDB
cwmp_exchanges)] + ESS -->|emit event| EE[EventEmitter
in-process] + EE -->|push| SSE[SSE Controller] + API[Exchange API Controller] -->|query| DB + end + + subgraph "Frontend (Vue 3 + Inertia)" + DD[DeviceDetail.vue] --> SV[SequenceViewer.vue] + SV --> IP[InspectorPanel.vue] + SV -->|EventSource| SSE + SV -->|fetch history| API + SV --> UES[useExchangeStream composable] + end + + CPE[CPE Device] -->|SOAP/HTTP| CWM + SSE -->|text/event-stream| SV +``` + +### Communication Flow + +1. Le CPE envoie un message SOAP au endpoint `/cwmp10` +2. `CwmpLoggerMiddleware` capture le request body ET le response body (via interception de `response.send`) +3. Si le device est tracé, le middleware appelle `ExchangeStoreService.persistExchange()` +4. `ExchangeStoreService` persiste en MongoDB puis émet un événement via `EventEmitter` +5. Le `SSE Controller` écoute l'EventEmitter pour le deviceId en question et pousse l'événement au client +6. Le composable `useExchangeStream` reçoit l'événement SSE et met à jour le state réactif +7. `SequenceViewer.vue` affiche le nouveau message dans le diagramme de séquence + +## Components and Interfaces + +### Backend Services + +#### ExchangeStoreService + +Service singleton responsable de la persistance et de l'émission d'événements. + +```typescript +import { EventEmitter } from 'node:events' + +interface CwmpExchange { + deviceId: string + direction: 'CPE→ACS' | 'ACS→CPE' + timestamp: string // ISO 8601 UTC + sessionId: string + cwmpMethod: string // Extracted from SOAP body, or "Unknown"/"Empty" + rawXml: string // Max 2MB + sequenceNumber: number // Monotonically increasing per device, starting at 1 +} + +interface PersistOptions { + deviceId: string + direction: 'CPE→ACS' | 'ACS→CPE' + sessionId: string + rawXml: string +} + +class ExchangeStoreService { + private static instance: ExchangeStoreService + private emitter: EventEmitter + + static getInstance(): ExchangeStoreService + + /** Persist an exchange and emit SSE event */ + async persistExchange(options: PersistOptions): Promise + + /** Extract CWMP method name from SOAP XML body */ + extractCwmpMethod(xml: string): string + + /** Get next sequence number for a device (atomic increment) */ + private async getNextSequenceNumber(deviceId: string): Promise + + /** Retry logic for critical messages */ + private async persistWithRetry(doc: CwmpExchange, isCritical: boolean): Promise + + /** Enforce max 5000 messages cap per device */ + private async enforceMessageCap(deviceId: string): Promise + + /** Subscribe to exchange events for a specific device */ + subscribe(deviceId: string, listener: (exchange: CwmpExchange) => void): void + + /** Unsubscribe from exchange events */ + unsubscribe(deviceId: string, listener: (exchange: CwmpExchange) => void): void +} +``` + +**Design Rationale — In-process EventEmitter vs MongoDB Change Streams:** +- adam-acs est déployé en instance unique (pas de cluster), donc un EventEmitter in-process suffit +- Pas de dépendance à un replica set MongoDB (change streams requièrent un replica set) +- Latence minimale : l'événement est émis immédiatement après la persistance +- Si un jour on passe en multi-instance, on pourra migrer vers Redis Pub/Sub sans modifier l'interface + +#### Exchange API Controller + +```typescript +class ExchangeController { + /** GET /api/v1/tr069-trace/:deviceId/exchanges */ + async index({ params, request, response }: HttpContext): Promise + + /** DELETE /api/v1/tr069-trace/:deviceId/exchanges */ + async destroy({ params, response }: HttpContext): Promise + + /** GET /api/v1/tr069-trace/:deviceId/exchanges/stream (SSE) */ + async stream({ params, request, response }: HttpContext): Promise +} +``` + +#### SSE Stream Implementation + +```typescript +// Inside stream() method +async stream({ params, request, response }: HttpContext) { + const { deviceId } = params + const traceService = Tr069TraceService.getInstance() + + if (!traceService.isTraced(deviceId)) { + return response.status(404).json({ error: 'Device is not being traced' }) + } + + // Set SSE headers + response.response.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + }) + + // Handle Last-Event-ID for reconnection + const lastEventId = request.header('Last-Event-ID') + if (lastEventId) { + const missedExchanges = await ExchangeModel.findAfterSequence(deviceId, Number(lastEventId)) + for (const exchange of missedExchanges) { + this.sendSSEEvent(response, exchange) + } + } + + // Subscribe to new events + const listener = (exchange: CwmpExchange) => { + this.sendSSEEvent(response, exchange) + } + const exchangeStore = ExchangeStoreService.getInstance() + exchangeStore.subscribe(deviceId, listener) + + // Timeout management + const inactivityTimeout = 5 * 60 * 1000 // 5 minutes + const absoluteTimeout = 2 * 60 * 60 * 1000 // 2 hours + let inactivityTimer = setTimeout(() => close(), inactivityTimeout) + const absoluteTimer = setTimeout(() => close(), absoluteTimeout) + + function resetInactivityTimer() { + clearTimeout(inactivityTimer) + inactivityTimer = setTimeout(() => close(), inactivityTimeout) + } + + function close() { + exchangeStore.unsubscribe(deviceId, listener) + clearTimeout(inactivityTimer) + clearTimeout(absoluteTimer) + response.response.end() + } + + // Client disconnect + request.request.on('close', close) +} + +private sendSSEEvent(response: HttpContext['response'], exchange: CwmpExchange) { + const data = JSON.stringify(exchange) + response.response.write(`id: ${exchange.sequenceNumber}\ndata: ${data}\n\n`) +} +``` + +### MongoDB Model + +#### ExchangeModel + +```typescript +import { BaseModel } from '#database/base_model' + +export default class ExchangeModel extends BaseModel { + static collectionName = 'cwmp_exchanges' + + /** Find exchanges for a device, ordered by timestamp ascending */ + static async findByDevice( + deviceId: string, + options?: { limit?: number; after?: string } + ): Promise + + /** Find exchanges with sequence number greater than given value */ + static async findAfterSequence(deviceId: string, seqNumber: number): Promise + + /** Delete all exchanges for a device */ + static async deleteByDevice(deviceId: string): Promise + + /** Get the latest sequence number for a device */ + static async getMaxSequenceNumber(deviceId: string): Promise + + /** Count exchanges for a device */ + static async countByDevice(deviceId: string): Promise + + /** Delete oldest exchanges exceeding the cap */ + static async deleteOldestBeyondCap(deviceId: string, maxCount: number): Promise + + /** Check if any exchange exists for a device (ever traced) */ + static async hasExchanges(deviceId: string): Promise + + /** Get metadata (oldest and newest timestamps) */ + static async getMetadata(deviceId: string): Promise<{ + recordedFrom: string | null + recordedUntil: string | null + count: number + }> + + /** Ensure indexes exist */ + static async ensureIndexes(): Promise +} +``` + +### Frontend Components + +#### SequenceViewer.vue + +```typescript +// Props +interface SequenceViewerProps { + deviceId: string + device: { + identifier: string + manufacturer: string + productClass: string + serialNumber: string + } + traceStartedAt: string +} + +// Component responsibilities: +// - Fetches initial exchange history via REST API +// - Connects to SSE stream for real-time updates +// - Renders CPE/ACS lifelines with message arrows +// - Manages selection state for Inspector Panel +// - Implements virtual scrolling for >200 messages +// - Handles auto-scroll pause/resume +``` + +#### InspectorPanel.vue + +```typescript +// Props +interface InspectorPanelProps { + selectedMessage: CwmpExchange | null +} + +// Component responsibilities: +// - Displays formatted XML with 2-space indentation +// - Syntax highlighting for XML elements/attributes/values +// - Copy to clipboard functionality +// - Resizable panel (300px min, 70% viewport max) +// - Header showing cwmpMethod + direction +``` + +#### useExchangeStream composable + +```typescript +interface UseExchangeStreamReturn { + messages: Ref + connectionState: Ref<'connected' | 'paused' | 'disconnected'> + isAutoScrollEnabled: Ref + connect: () => void + disconnect: () => void + resumeAutoScroll: () => void + clearMessages: () => Promise +} + +function useExchangeStream(deviceId: string): UseExchangeStreamReturn +``` + +### Modified Existing Components + +#### CwmpLoggerMiddleware (modified) + +```typescript +// Changes: +// - Instead of calling traceService.writeExchange() (file-based) +// - Call ExchangeStoreService.getInstance().persistExchange() +// - Remove prettyXml formatting (raw XML stored as-is) +// - Keep brief app-level logging for non-traced devices + +async handle(ctx: HttpContext, next: NextFn) { + // ... existing request capture logic ... + await next() + + const deviceId = ctx.cwmpDeviceId + const traceService = Tr069TraceService.getInstance() + const isTraced = deviceId ? traceService.isTraced(deviceId) : false + + if (isTraced && deviceId) { + const exchangeStore = ExchangeStoreService.getInstance() + if (rawBody && rawBody.trim() !== '') { + await exchangeStore.persistExchange({ + deviceId, + direction: 'CPE→ACS', + sessionId, + rawXml: rawBody, + }) + } + if (capturedResponseXml) { + await exchangeStore.persistExchange({ + deviceId, + direction: 'ACS→CPE', + sessionId, + rawXml: capturedResponseXml, + }) + } + } +} +``` + +## Data Models + +### MongoDB Collection: `cwmp_exchanges` + +```json +{ + "_id": "ObjectId", + "deviceId": "string (device identifier)", + "direction": "CPE→ACS | ACS→CPE", + "timestamp": "ISODate (UTC)", + "sessionId": "string (IP:port->IP:port)", + "cwmpMethod": "string (Inform, GetParameterValues, Unknown, Empty, etc.)", + "rawXml": "string (max 2MB raw SOAP XML)", + "sequenceNumber": "number (monotonic per device, starts at 1)", + "createdAt": "ISODate" +} +``` + +### Indexes + +| Index | Fields | Options | Purpose | +|-------|--------|---------|---------| +| Primary query | `{ deviceId: 1, timestamp: 1 }` | — | Efficient retrieval by device, ordered by time | +| Sequence lookup | `{ deviceId: 1, sequenceNumber: 1 }` | `unique: true` | Atomic sequence numbering, Last-Event-ID queries | +| TTL cleanup | `{ createdAt: 1 }` | `expireAfterSeconds: 86400` | Auto-remove exchanges older than 24h | + +### Sequence Number Generation + +Pour garantir la monotonie sans verrouillage global, on utilise un compteur atomique dans une collection auxiliaire : + +```json +// Collection: cwmp_exchange_counters +{ + "_id": "deviceId string", + "seq": 42 // current max sequence number +} +``` + +```typescript +// Atomic increment via findOneAndUpdate + upsert +async getNextSequenceNumber(deviceId: string): Promise { + const counters = getMongoDb().collection('cwmp_exchange_counters') + const result = await counters.findOneAndUpdate( + { _id: deviceId }, + { $inc: { seq: 1 } }, + { upsert: true, returnDocument: 'after' } + ) + return result!.seq +} +``` + +### Critical vs Non-Critical Message Classification + +| Category | CWMP Methods | Retry Policy | +|----------|-------------|--------------| +| Critical | Inform, GetParameterValues, GetParameterValuesResponse, SetParameterValues, SetParameterValuesResponse, AddObject, DeleteObject, Download, Upload, Reboot, FactoryReset, Fault | 3 retries, exponential backoff (1s, 2s, 4s) | +| Non-critical | Empty, Unknown | Log error, discard immediately | + +### API Routes (new) + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/tr069-trace/:deviceId/exchanges` | Retrieve exchanges (paginated) | +| DELETE | `/api/v1/tr069-trace/:deviceId/exchanges` | Purge all exchanges for device | +| GET | `/api/v1/tr069-trace/:deviceId/exchanges/stream` | SSE endpoint for real-time | + +### Exchange API Response Format + +```json +// GET /api/v1/tr069-trace/:deviceId/exchanges +{ + "exchanges": [ + { + "sequenceNumber": 1, + "timestamp": "2025-01-15T10:30:00.123Z", + "direction": "CPE→ACS", + "cwmpMethod": "Inform", + "sessionId": "10.0.0.1:54321->192.168.1.1:8080", + "rawXml": "..." + } + ], + "metadata": { + "traceStatus": "ACTIVE", + "traceStoppedAt": null, + "recordedFrom": "2025-01-15T10:30:00.123Z", + "recordedUntil": "2025-01-15T10:35:42.567Z", + "total": 42 + } +} +``` + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: CWMP Method Extraction + +*For any* SOAP XML body, the `extractCwmpMethod` function SHALL return the first child element name under the `cwmp:` namespace prefix if one exists, "Empty" if the XML is empty/whitespace-only, or "Unknown" if no `cwmp:` namespace element is found. + +**Validates: Requirements 1.2, 1.3, 1.6** + +### Property 2: Sequence Number Monotonicity + +*For any* series of exchanges persisted for a given device, the sequence numbers SHALL be strictly monotonically increasing (each new exchange's sequence number is exactly one greater than the previous). + +**Validates: Requirements 1.1** + +### Property 3: Message Cap Enforcement + +*For any* device, after any number of message insertions, the total count of stored exchanges SHALL never exceed 5000 and the retained messages SHALL always be the most recent ones (by timestamp). + +**Validates: Requirements 1.4** + +### Property 4: Retry Classification Correctness + +*For any* CWMP message where a MongoDB write failure occurs, if the cwmpMethod is in the critical set (Inform, GetParameterValues, SetParameterValuesResponse, Fault, etc.) then exactly 3 retry attempts with exponential backoff SHALL be made; if the cwmpMethod is non-critical (Empty, Unknown), the message SHALL be discarded immediately with zero retries. + +**Validates: Requirements 1.7** + +### Property 5: API Result Ordering + +*For any* set of persisted exchanges and any valid query to the exchanges API, the returned array SHALL be ordered by timestamp ascending with no inversions. + +**Validates: Requirements 2.1** + +### Property 6: Limit Parameter Validation and Capping + +*For any* numeric limit value in the range [1, 1000], the API SHALL return at most `limit` messages; for any omitted limit, the default SHALL be 200; for any limit value that is non-numeric or < 1, the API SHALL return HTTP 400. + +**Validates: Requirements 2.2, 2.3** + +### Property 7: After Parameter Filtering + +*For any* valid ISO 8601 timestamp passed as the `after` query parameter, all returned exchanges SHALL have a timestamp strictly greater than the specified value; for any invalid timestamp string, the API SHALL return HTTP 400. + +**Validates: Requirements 2.4, 2.5** + +### Property 8: Session Header Completeness + +*For any* device with any combination of metadata (identifier, manufacturer, productClass, serialNumber), the rendered session header in the Sequence_Viewer SHALL contain all four fields plus the trace start time. + +**Validates: Requirements 3.4** + +### Property 9: Arrow Direction Correctness + +*For any* list of CWMP messages with mixed directions, the Sequence_Viewer SHALL render each message arrow pointing left-to-right for "CPE→ACS" and right-to-left for "ACS→CPE", with the overall list ordered by timestamp ascending. + +**Validates: Requirements 4.2** + +### Property 10: Method Label Truncation + +*For any* cwmpMethod name, if its length exceeds 40 characters, the displayed label SHALL be truncated to 40 characters and a tooltip SHALL contain the full name; if length ≤ 40, the full name SHALL be displayed without truncation. + +**Validates: Requirements 4.3** + +### Property 11: Timestamp Formatting + +*For any* valid ISO 8601 timestamp, the formatted display SHALL match the pattern `HH:mm:ss.SSS` (hours:minutes:seconds.milliseconds). + +**Validates: Requirements 4.4** + +### Property 12: Inspector Panel Display on Selection + +*For any* selected CWMP message with non-empty rawXml, the Inspector Panel SHALL display the XML formatted with 2-space indentation AND a header showing the cwmpMethod name and direction ("CPE→ACS" or "ACS→CPE"). + +**Validates: Requirements 5.1, 5.2** + +### Property 13: SSE Event ID Consistency + +*For any* CWMP exchange emitted via SSE, the event `id` field SHALL equal the exchange's `sequenceNumber`, ensuring unique and ordered event identification for reconnection support. + +**Validates: Requirements 6.9** + +### Property 14: SSE Reconnection Continuity (Last-Event-ID) + +*For any* SSE reconnection providing a `Last-Event-ID` of value N, the server SHALL resume streaming only exchanges with sequence number > N, producing zero duplicate messages. + +**Validates: Requirements 6.8** + +## Error Handling + +### Backend Error Scenarios + +| Scenario | Handling | User Impact | +|----------|----------|-------------| +| MongoDB write failure (critical msg) | 3 retries with exponential backoff (1s, 2s, 4s), then discard + log error | Message may be lost after 3 failures, CWMP session unaffected | +| MongoDB write failure (non-critical) | Log error, discard immediately | Empty/Unknown message lost, no user impact | +| MongoDB read timeout (>5s) | Return partial results with `isTruncated: true` | User sees recent messages + warning indicator | +| SSE connection lost (client side) | EventSource auto-reconnect with 3s retry + Last-Event-ID | Brief "disconnected" indicator, no message loss | +| SSE inactivity timeout (5min) | Server closes connection gracefully | Client auto-reconnects, resumes from Last-Event-ID | +| SSE absolute timeout (2h) | Server closes connection | Client auto-reconnects | +| Device not traced (SSE connect attempt) | Return 404 immediately | User sees "device not traced" message | +| rawXml exceeds 2MB | Truncate to 2MB, log warning | User sees truncated XML with indicator | +| Invalid XML for formatting | Display raw unformatted XML | User can still inspect raw content | + +### Frontend Error Handling + +```typescript +// useExchangeStream composable error states +interface ErrorState { + type: 'network' | 'server' | 'parse' + message: string + retryable: boolean +} + +// Auto-recovery: on SSE error, the composable: +// 1. Sets connectionState to 'disconnected' +// 2. Preserves all currently displayed messages +// 3. Relies on EventSource built-in reconnection (3s retry) +// 4. On reconnect: sends Last-Event-ID automatically +// 5. After 3 failed reconnection attempts: shows persistent error banner +``` + +### Graceful Degradation + +- If SSE is unavailable, the viewer falls back to REST polling every 5s +- If MongoDB is down during initial load, show empty state with error banner +- If XML formatting fails, show raw XML without syntax highlighting + +## Testing Strategy + +### Property-Based Testing (fast-check) + +Le projet utilise déjà `fast-check` (v4.8.0) et `vitest` (v4.1.7). Chaque propriété du design sera implémentée comme un test property-based avec minimum 100 itérations. + +**Configuration :** +```typescript +import fc from 'fast-check' +import { describe, it, expect } from 'vitest' + +// Minimum 100 iterations per property +const PBT_NUM_RUNS = 100 +``` + +**Tests property-based à implémenter :** + +| Property | Test File | Description | +|----------|-----------|-------------| +| 1: CWMP Method Extraction | `tests/unit/services/exchange_store.property.spec.ts` | Random XML → correct method extraction | +| 2: Sequence Monotonicity | `tests/unit/services/exchange_store.property.spec.ts` | Random insertions → strictly increasing seq | +| 3: Message Cap | `tests/unit/services/exchange_store.property.spec.ts` | Random inserts → count ≤ 5000, oldest removed | +| 4: Retry Classification | `tests/unit/services/exchange_store.property.spec.ts` | Random methods → correct retry behavior | +| 5: API Ordering | `tests/unit/controllers/exchange_controller.property.spec.ts` | Random messages → timestamp ascending | +| 6: Limit Validation | `tests/unit/controllers/exchange_controller.property.spec.ts` | Random limits → correct capping/400 | +| 7: After Filtering | `tests/unit/controllers/exchange_controller.property.spec.ts` | Random timestamps → correct filtering | +| 8: Session Header | `tests/unit/components/sequence_viewer.property.spec.ts` | Random device data → all fields present | +| 9: Arrow Direction | `tests/unit/components/sequence_viewer.property.spec.ts` | Random messages → correct arrow directions | +| 10: Label Truncation | `tests/unit/components/sequence_viewer.property.spec.ts` | Random method names → truncation at 40 | +| 11: Timestamp Format | `tests/unit/components/sequence_viewer.property.spec.ts` | Random timestamps → HH:mm:ss.SSS pattern | +| 12: Inspector Display | `tests/unit/components/inspector_panel.property.spec.ts` | Random messages → formatted XML + header | +| 13: SSE Event ID | `tests/unit/controllers/exchange_controller.property.spec.ts` | Random events → id matches sequenceNumber | +| 14: Reconnection | `tests/integration/sse_reconnection.property.spec.ts` | Random disconnect points → no duplicates | + +**Tagging format :** +```typescript +// Feature: tr069-exchange-viewer, Property 1: CWMP Method Extraction +it.prop([fc.string()], { numRuns: PBT_NUM_RUNS }, (xml) => { ... }) +``` + +### Unit Tests (example-based) + +| Area | Test Focus | +|------|------------| +| ExchangeStoreService | Empty XML handling, 2MB truncation, known CWMP methods | +| ExchangeController | 404 for never-traced device, 200 empty array for traced-no-messages, metadata in stopped-trace response, DELETE returns 204 | +| SequenceViewer | Empty state placeholder (no lifelines), selection/deselection UI behavior | +| InspectorPanel | Copy to clipboard, empty/unparseable XML display, placeholder when no selection | +| SSE endpoint | 404 for non-traced device, correct Content-Type headers | + +### Integration Tests + +| Scenario | Description | +|----------|-------------| +| Full exchange lifecycle | Capture SOAP → persist → SSE event → frontend display | +| SSE reconnection | Connect, receive messages, disconnect, reconnect with Last-Event-ID | +| Purge flow | Clear exchanges → verify empty state → continue receiving | +| TTL expiration | Insert old exchanges → verify auto-cleanup after 24h | +| Message cap | Insert 5001 messages → verify oldest removed | + +### Smoke Tests + +| Check | Description | +|-------|-------------| +| Index verification | `cwmp_exchanges` indexes exist after app start | +| TTL index | TTL index configured with 86400 seconds | +| SSE endpoint accessible | Can open connection to stream endpoint | + + +## Security Considerations + +### Input Validation + +| Input | Validation | Rationale | +|-------|-----------|-----------| +| `deviceId` | Alphanumeric + hyphens, max 128 chars | Prevent injection | +| `limit` | Integer 1-1000 | Prevent DoS via huge queries | +| `after` | Valid ISO 8601 timestamp | Prevent malformed queries | +| `rawXml` | Max 2MB, valid UTF-8 | Prevent storage exhaustion | + +### Authorization + +```typescript +// Middleware: Only authenticated users can access exchanges +async function checkTraceAccess(ctx: HttpContext, next: NextFn) { + const { deviceId } = ctx.params + const user = ctx.auth.user! + + // Check if user has permission to view this device + const hasAccess = await user.can('view_device_trace', deviceId) + if (!hasAccess) { + return ctx.response.forbidden() + } + + await next() +} +``` + +### Data Privacy + +- **PII in XML**: If SOAP messages contain PII (phone numbers, MAC addresses), implement: + - Audit logging of who accessed what + - Optional masking of sensitive fields in UI + - Retention policy (auto-delete after 24h by default) + +### Rate Limiting + +```typescript +// Limit SSE connections per device +const MAX_SSE_CONNECTIONS_PER_DEVICE = 5 + +// Limit API requests +Route.get('/exchanges', middleware.throttle({ + requests: 100, + duration: '1 minute' +})) +``` + +### CORS & CSP + +```typescript +// config/cors.ts +export const cors = { + enabled: true, + origin: ['https://trusted-domain.com'], + credentials: true, + methods: ['GET', 'DELETE'], +} + +// config/csp.ts +export const csp = { + directives: { + defaultSrc: ["'self'"], + connectSrc: ["'self'", 'wss://trusted-domain.com'], + } +} +``` + +## Performance Considerations + +### Virtual Scrolling Implementation + +For sessions > 200 messages: + +```typescript +// SequenceViewer.vue + + +// Expected performance: +// - 1000 messages: 16ms render time (60 FPS) +// - 5000 messages: 45ms render time (acceptable) +``` + +### Lazy Loading Strategy + +```typescript +// Load messages in batches of 50 +const BATCH_SIZE = 50 + +async function loadMoreMessages() { + const offset = messages.value.length + const batch = await api.getExchanges(deviceId, { + limit: BATCH_SIZE, + offset: offset + }) + messages.value.push(...batch) +} + +// Trigger on scroll near bottom +const observer = new IntersectionObserver(([entry]) => { + if (entry.isIntersecting && !isLoading.value) { + loadMoreMessages() + } +}) +observer.observe(loadMoreTrigger) +``` + +### Memory Management + +```typescript +// Limit in-memory message cache to 1000 most recent +const MAX_CACHED_MESSAGES = 1000 + +watch(() => messages.value.length, (newLength) => { + if (newLength > MAX_CACHED_MESSAGES) { + messages.value = messages.value.slice(-MAX_CACHED_MESSAGES) + } +}) +``` + +### Network Optimization + +- **Compression**: Enable gzip for SSE stream +- **Message batching**: If > 10 messages/sec, batch into single SSE event +- **Selective fields**: API returns only essential fields by default, full XML on demand + +## Deployment & Operations + +### Database Migration + +**Initial setup (one-time):** + +```typescript +// Migration file: database/migrations/XXXX_create_cwmp_exchanges.ts +export default class CreateCwmpExchanges { + async up(db: Db) { + // Create collection + await db.createCollection('cwmp_exchanges') + + // Create indexes + await db.collection('cwmp_exchanges').createIndex( + { deviceId: 1, timestamp: 1 } + ) + await db.collection('cwmp_exchanges').createIndex( + { deviceId: 1, sequenceNumber: 1 }, + { unique: true } + ) + await db.collection('cwmp_exchanges').createIndex( + { createdAt: 1 }, + { expireAfterSeconds: 86400 } + ) + + // Create counters collection + await db.createCollection('cwmp_exchange_counters') + } + + async down(db: Db) { + await db.dropCollection('cwmp_exchanges') + await db.dropCollection('cwmp_exchange_counters') + } +} +``` + +### Monitoring & Alerting + +#### Key Metrics to Monitor + +| Metric | Threshold | Alert | +|--------|-----------|-------| +| SSE connection count | > 100 concurrent | High SSE load | +| MongoDB write latency | > 100ms p95 | Slow persistence | +| Exchange storage size | > 10GB | Storage growth | +| TTL cleanup lag | > 1h | TTL index issue | +| Message loss rate | > 0.1% | Critical failures | +| SSE reconnection rate | > 10/min per device | Network instability | + +#### Health Check Endpoint + +```typescript +// GET /api/v1/health/tr069-exchanges +{ + "status": "healthy", + "mongodb": { + "connected": true, + "latency_ms": 5, + "collections": { + "cwmp_exchanges": { "count": 12345, "size_mb": 234 } + } + }, + "sse": { + "active_connections": 42, + "reconnections_1h": 3 + }, + "timestamp": "2025-01-15T10:30:00Z" +} +``` + +### Rollback Strategy + +If critical issues arise: + +1. **Disable SSE**: Set feature flag `FEATURE_SSE_ENABLED=false` → fallback to REST polling +2. **Disable persistence**: Set `FEATURE_EXCHANGE_PERSISTENCE=false` → continue CWMP without logging +3. **Revert to file-based**: Keep old `Tr069TraceService` as fallback for 1 release cycle + +## Backward Compatibility & Migration Path + +### Phase 1: Dual-Write (Week 1-2) + +```typescript +// CwmpLoggerMiddleware writes to BOTH systems +if (isTraced && deviceId) { + // New system + await ExchangeStoreService.getInstance().persistExchange({...}) + + // Old system (for rollback safety) + Tr069TraceService.getInstance().writeExchange(deviceId, direction, sessionId, xml) +} +``` + +### Phase 2: Read from New System (Week 3-4) + +```typescript +// DeviceDetail.vue tries new API first, falls back to old +const exchanges = await api.getExchanges(deviceId) + .catch(() => legacyService.readExchanges(deviceId)) +``` + +### Phase 3: Deprecate Old System (Week 5+) + +- Remove old file-based writes +- Keep read fallback for 2 more releases +- Delete legacy log files + +### Data Migration Script + +```typescript +// scripts/migrate_exchanges.ts +// For devices with existing .log files, import into MongoDB +async function migrateExistingLogs() { + const logFiles = await fs.readdir(LOG_DIR) + + for (const file of logFiles) { + const deviceId = file.replace('.log', '') + const exchanges = parseLogFile(join(LOG_DIR, file)) + + for (const exchange of exchanges) { + await ExchangeModel.create({ + deviceId, + direction: exchange.direction, + timestamp: exchange.timestamp, + sessionId: exchange.sessionId, + cwmpMethod: extractCwmpMethod(exchange.xml), + rawXml: exchange.xml, + sequenceNumber: await getNextSeq(deviceId), + createdAt: new Date(exchange.timestamp), + }) + } + } +} +``` + +## Configuration + +### Environment Variables + +```bash +# MongoDB +MONGODB_URL=mongodb://localhost:27017/adam-acs +MONGODB_POOL_SIZE=10 + +# Exchange Store +EXCHANGE_STORE_MAX_MESSAGES_PER_DEVICE=5000 +EXCHANGE_STORE_TTL_HOURS=24 +EXCHANGE_STORE_BATCH_SIZE=50 + +# SSE +SSE_INACTIVITY_TIMEOUT_MS=300000 # 5 minutes +SSE_ABSOLUTE_TIMEOUT_MS=7200000 # 2 hours +SSE_RECONNECT_INTERVAL_MS=3000 # 3 seconds +SSE_MAX_CONNECTIONS_PER_DEVICE=5 + +# Feature Flags +FEATURE_EXCHANGE_PERSISTENCE=true +FEATURE_SSE_ENABLED=true +FEATURE_VIRTUAL_SCROLLING=true +FEATURE_DUAL_WRITE=true + +# Performance +VIRTUAL_SCROLL_BUFFER_SIZE=5 +LAZY_LOAD_BATCH_SIZE=50 +MAX_CACHED_MESSAGES=1000 +``` + +### Runtime Configuration + +```typescript +// config/exchange_store.ts +export const exchangeStore = { + maxMessagesPerDevice: env.get('EXCHANGE_STORE_MAX_MESSAGES_PER_DEVICE', 5000), + ttlHours: env.get('EXCHANGE_STORE_TTL_HOURS', 24), + batchSize: env.get('EXCHANGE_STORE_BATCH_SIZE', 50), + retryPolicy: { + maxAttempts: 3, + backoffMs: [1000, 2000, 4000], + }, + sse: { + inactivityTimeoutMs: env.get('SSE_INACTIVITY_TIMEOUT_MS', 300000), + absoluteTimeoutMs: env.get('SSE_ABSOLUTE_TIMEOUT_MS', 7200000), + reconnectIntervalMs: env.get('SSE_RECONNECT_INTERVAL_MS', 3000), + maxConnectionsPerDevice: env.get('SSE_MAX_CONNECTIONS_PER_DEVICE', 5), + }, +} +``` + +## Known Limitations & Future Enhancements + +### Current Limitations + +| Limitation | Impact | Workaround | +|-----------|--------|-----------| +| Single-instance only | No horizontal scaling | Deploy multiple instances with Redis Pub/Sub (future) | +| 24h TTL fixed | Cannot retain long-term history | Archive to S3 before TTL expires (future) | +| 5000 msg cap per device | Large sessions truncated | Implement pagination/archiving (future) | +| No message filtering | Cannot search by method | Add full-text search index (future) | +| No message comparison | Cannot diff two exchanges | Add side-by-side diff view (future) | + +### Future Enhancements (Post-MVP) + +1. **Multi-instance support**: Replace EventEmitter with Redis Pub/Sub +2. **Long-term archival**: Auto-archive old exchanges to S3/GCS +3. **Advanced search**: Full-text search on XML content +4. **Message filtering**: Filter by cwmpMethod, direction, timestamp range +5. **Diff viewer**: Compare two exchanges side-by-side +6. **Export**: Download exchanges as CSV/JSON/HAR +7. **Replay**: Simulate device behavior by replaying captured exchanges +8. **Analytics**: Charts showing message frequency, error rates, latency +9. **Webhooks**: Notify external systems on specific CWMP methods +10. **Multi-language**: Support for non-English CWMP method names + +### Scalability Roadmap + +``` +MVP (current) +├─ Single instance +├─ In-process EventEmitter +├─ 5000 msg/device cap +└─ 24h TTL + +Phase 2 (future) +├─ Redis Pub/Sub for multi-instance +├─ 50k msg/device cap +└─ Configurable TTL + +Phase 3 (future) +├─ S3 archival for long-term storage +├─ Elasticsearch for full-text search +└─ Horizontal scaling to 10+ instances +``` + +## Correctness Properties — Examples + +### Property 1: CWMP Method Extraction (Example) + +**Input XML:** +```xml + + + + ... + + + +``` + +**Expected output:** `"Inform"` + +**Edge cases tested:** +- Empty XML → `"Empty"` +- Malformed XML (no closing tags) → `"Unknown"` +- Multiple `cwmp:` elements → first one extracted +- No `cwmp:` namespace → `"Unknown"` +- Whitespace-only body → `"Empty"` +- `cwmp:GetParameterValuesResponse` → `"GetParameterValuesResponse"` diff --git a/.kiro/specs/tr069-exchange-viewer/requirements.md b/.kiro/specs/tr069-exchange-viewer/requirements.md new file mode 100644 index 0000000..3cd94ba --- /dev/null +++ b/.kiro/specs/tr069-exchange-viewer/requirements.md @@ -0,0 +1,138 @@ +# Requirements Document + +## Introduction + +Ce document spécifie les exigences pour le système de visualisation interactive des échanges TR-069/CWMP. L'objectif est de remplacer le viewer de log brut actuel par une interface de type diagramme de séquence CPE ↔ ACS, permettant d'inspecter individuellement chaque message SOAP échangé lors d'une session de trace. Le stockage passe d'un format texte brut (fichiers `.log`) à un stockage structuré en MongoDB. L'UI utilise le framework Boosted (Orange CSS basé sur Bootstrap) et s'intègre à la page DeviceDetail existante. + +## Glossary + +- **Exchange_Store**: Service backend responsable du stockage et de la récupération des échanges CWMP structurés dans MongoDB +- **Exchange_API**: Endpoints REST exposant les échanges structurés d'un device tracé +- **Sequence_Viewer**: Composant frontend Vue 3 affichant le diagramme de séquence CPE ↔ ACS avec les messages cliquables +- **Inspector_Panel**: Panneau latéral affichant le contenu XML SOAP formaté du message sélectionné +- **CWMP_Message**: Un échange SOAP individuel capturé (requête ou réponse) avec ses métadonnées (direction, timestamp, type CWMP, XML brut) +- **Trace_Session**: Période pendant laquelle un device est en mode trace, regroupant tous les échanges capturés +- **CPE**: Customer Premises Equipment — l'équipement réseau du client qui communique avec l'ACS via TR-069 +- **ACS**: Auto-Configuration Server — le serveur adam-acs qui gère les CPE via le protocole CWMP +- **Boosted**: Framework CSS Orange basé sur Bootstrap, utilisé pour le design system de l'application +- **SSE**: Server-Sent Events — protocole HTTP unidirectionnel (serveur → client) permettant au backend de pousser des événements en temps réel vers le navigateur via une connexion persistante + +## Requirements + +### Requirement 1: Stockage structuré des échanges + +**User Story:** As a developer, I want CWMP exchanges to be stored in a structured format in MongoDB, so that individual messages can be queried and displayed independently. + +#### Acceptance Criteria + +1. WHEN CwmpLoggerMiddleware captures a SOAP exchange for a traced device, THE Exchange_Store SHALL persist a CWMP_Message document in MongoDB containing: deviceId, direction (CPE→ACS or ACS→CPE), timestamp (ISO 8601 UTC format), sessionId, cwmpMethod (type de message CWMP extrait du XML), rawXml (limited to 2 MB maximum per message), and a monotonically increasing sequence number per device starting at 1 +2. THE Exchange_Store SHALL extract the CWMP method name from the SOAP body element by reading the first child element name under the `cwmp:` namespace prefix (e.g., "Inform", "GetParameterValues", "SetParameterValuesResponse") and store it in the cwmpMethod field +3. IF the SOAP body contains no element with the `cwmp:` namespace prefix (malformed or unrecognized XML), THEN THE Exchange_Store SHALL persist the message with cwmpMethod set to "Unknown" and store the original rawXml unchanged +4. WHILE a device is in trace mode, THE Exchange_Store SHALL persist all exchanges in chronological order, retaining a maximum of 5000 messages per device, and deleting the oldest messages when this limit is exceeded. THE Exchange_Store SHALL NOT persist messages for devices that are not currently in trace mode +5. THE Exchange_Store SHALL create a MongoDB index on (deviceId, timestamp) to support efficient retrieval of exchanges by device +6. IF the XML body is empty (empty POST from CPE), THEN THE Exchange_Store SHALL persist the message with cwmpMethod set to "Empty" and rawXml set to an empty string +7. IF a MongoDB write failure occurs during message persistence, THEN: + - FOR critical messages (RPC Calls, Responses, and Faults such as Inform, GetParameterValues, SetParameterValuesResponse, Fault): THE Exchange_Store SHALL attempt 3 retries with exponential backoff (1s, 2s, 4s) before discarding + - FOR non-critical messages (Empty posts, Unknown method types): THE Exchange_Store SHALL log the error and discard the message immediately + - In both cases, THE Exchange_Store SHALL NOT interrupt the CWMP session processing + +### Requirement 2: API de récupération des échanges structurés + +**User Story:** As a frontend developer, I want a REST API to retrieve structured CWMP exchanges for a traced device, so that the sequence viewer can display them. + +#### Acceptance Criteria + +1. WHEN a GET request is made to `/api/v1/tr069-trace/:deviceId/exchanges`, THE Exchange_API SHALL return a JSON array of CWMP_Message objects ordered by timestamp ascending, where each CWMP_Message contains at minimum: timestamp (ISO 8601), direction (CPE→ACS or ACS→CPE), messageType (e.g. Inform, GetParameterValuesResponse), and sessionId +2. WHEN a GET request is made with a `limit` query parameter, THE Exchange_API SHALL cap the number of returned messages to the specified value, defaulting to 200 when omitted, and clamping to a maximum of 1000 +3. IF the `limit` query parameter is present but is non-numeric or less than 1, THEN THE Exchange_API SHALL return a 400 status with an error message indicating the valid range (1 to 1000) +4. WHEN a GET request is made with an `after` query parameter containing a valid ISO 8601 timestamp, THE Exchange_API SHALL return only messages with a timestamp strictly greater than the specified value, enabling incremental polling +5. IF the `after` query parameter is present but is not a valid ISO 8601 timestamp, THEN THE Exchange_API SHALL return a 400 status with an error message indicating the expected format +6. IF the device identified by `:deviceId` has never been traced (no historical exchanges exist), THEN THE Exchange_API SHALL return a 404 status with an error message indicating that no trace exists for this device +7. WHEN a GET request is made for a device that is being traced but has no recorded exchanges, THE Exchange_API SHALL return a 200 status with an empty JSON array +8. WHEN a GET request is made for a device that was previously traced but tracing is now stopped, THE Exchange_API SHALL return a 200 status with existing historical exchanges AND include metadata: traceStatus ("ACTIVE" or "STOPPED"), traceStoppedAt (ISO 8601 timestamp if stopped), recordedFrom (timestamp of oldest exchange), and recordedUntil (timestamp of newest exchange) +9. WHEN a DELETE request is made to `/api/v1/tr069-trace/:deviceId/exchanges`, THE Exchange_API SHALL remove all stored exchanges for the device and return a 204 status with no body + +### Requirement 3: Intégration dans DeviceDetail + +**User Story:** As an operator, I want to access the exchange viewer directly from the device detail page when the device is in trace mode, so that I can inspect protocol exchanges without navigating to a separate page. + +#### Acceptance Criteria + +1. WHILE a device is in trace mode, THE DeviceDetail page SHALL display a collapsible section containing the Sequence_Viewer, positioned after the Actions section +2. IF the device is not in trace mode, THEN THE DeviceDetail page SHALL display a button allowing the user to enable tracing for the device, within the Actions section. WHILE the device is in trace mode, THE button SHALL remain visible but disabled with a tooltip "Tracing already active. Click 'Disable Tracing' to stop." AND a secondary "Disable Tracing" button SHALL be displayed adjacent to it +3. WHEN the user enables tracing from DeviceDetail, THE DeviceDetail page SHALL display the Sequence_Viewer section showing an empty state message indicating that the system is waiting for the next device connection. The system SHALL allow tracing to be enabled regardless of response time constraints +4. WHILE a device is in trace mode, THE DeviceDetail page SHALL display a session header within the Sequence_Viewer section showing: device identifier, manufacturer, product class, serial number, and trace start time as recorded when tracing was enabled +5. WHEN the Sequence_Viewer section is displayed and no protocol exchanges have been recorded yet, THE DeviceDetail page SHALL display an empty state indicating that no exchanges are available and that data will appear on the next device connection + +### Requirement 4: Diagramme de séquence interactif + +**User Story:** As an operator, I want to see CWMP exchanges displayed as a sequence diagram with CPE and ACS lifelines, so that I can visually understand the protocol flow. + +#### Acceptance Criteria + +1. THE Sequence_Viewer SHALL display two vertical lifelines labeled "CPE" and "ACS" +2. THE Sequence_Viewer SHALL render each CWMP_Message as a horizontal arrow between the two lifelines, pointing in the direction of the message (CPE→ACS: left to right, ACS→CPE: right to left), ordered by timestamp ascending +3. THE Sequence_Viewer SHALL display the cwmpMethod name as a label on each arrow, truncated to 40 characters with a tooltip showing the full name if the label exceeds this length +4. THE Sequence_Viewer SHALL display the timestamp of each message in "HH:mm:ss.SSS" format below the method label +5. WHEN the user clicks on a message arrow, THE Sequence_Viewer SHALL apply a visually distinct background color to the selected message row, deselect any previously selected message, and display the selected message's content in the Inspector_Panel +6. THE Sequence_Viewer SHALL use distinct visual styling (color or line style) to differentiate CPE→ACS messages from ACS→CPE messages +7. THE Sequence_Viewer SHALL use Boosted CSS framework classes and Orange design tokens for all styling +8. WHEN no CWMP_Message exists for the current device, THE Sequence_Viewer SHALL display ONLY a placeholder message without rendering the lifelines, to avoid rendering empty UI elements + +### Requirement 5: Panneau d'inspection XML + +**User Story:** As an operator, I want to see the full formatted XML SOAP content of a selected message, so that I can debug protocol issues. + +#### Acceptance Criteria + +1. WHEN a message is selected in the Sequence_Viewer, THE Inspector_Panel SHALL display the rawXml content formatted with 2-space indentation and syntax highlighting that visually distinguishes XML elements, attributes, and values using distinct colors +2. WHEN a message is selected in the Sequence_Viewer, THE Inspector_Panel SHALL display a header showing the cwmpMethod name and the message direction expressed as "CPE→ACS" or "ACS→CPE" +3. WHEN the operator activates the "Copy" button, THE Inspector_Panel SHALL copy the raw unformatted XML content to the system clipboard and display a transient confirmation indicator visible for at least 2 seconds +4. THE Inspector_Panel SHALL be displayed as a resizable side panel on the right side of the Sequence_Viewer with a minimum width of 300px and a maximum width of 70% of the viewport width +5. WHEN no message is selected, THE Inspector_Panel SHALL display a placeholder text indicating the user should select a message to inspect +6. IF the rawXml content of the selected message is empty or cannot be parsed, THEN THE Inspector_Panel SHALL display a message indicating that no XML content is available for the selected message + +### Requirement 6: Mise à jour en temps réel via SSE + +**User Story:** As an operator, I want to see new exchanges appearing in the sequence diagram in real-time as they happen, so that I can monitor live protocol activity without delay. + +#### Acceptance Criteria + +1. THE Exchange_API SHALL expose a SSE endpoint at `GET /api/v1/tr069-trace/:deviceId/exchanges/stream` that opens a persistent HTTP connection with `Content-Type: text/event-stream` and pushes each new CWMP_Message as a JSON-encoded SSE `data` event as soon as it is persisted by the Exchange_Store +2. WHEN the Sequence_Viewer is opened for a traced device, THE Sequence_Viewer SHALL establish an EventSource connection to the SSE endpoint and listen for incoming messages +3. WHEN a new SSE event is received, THE Sequence_Viewer SHALL append the CWMP_Message to the bottom of the diagram while preserving the current scroll position and the currently selected message (if any) +4. THE Sequence_Viewer SHALL display a visual indicator distinguishing between three connection states: "connected" (SSE stream open and receiving), "paused" (auto-scroll paused but still connected), and "disconnected" (connection lost) +5. WHEN the user's scroll position is not at the bottom of the diagram AND the SSE connection is active, THE Sequence_Viewer SHALL pause auto-scroll but continue receiving SSE events in the background +6. WHILE auto-scroll is paused, THE Sequence_Viewer SHALL display a visible button allowing the user to resume auto-scroll, which scrolls the diagram to the latest message and re-enables auto-scroll +7. IF the SSE connection is lost (network error, server restart, or timeout), THEN THE Sequence_Viewer SHALL update the visual indicator to "disconnected" state, preserve all previously displayed messages, and attempt automatic reconnection using the EventSource built-in reconnection mechanism with a retry interval of 3 seconds +8. WHEN the SSE connection is re-established after a disconnection, THE Sequence_Viewer SHALL send the `Last-Event-ID` header (set to the sequence number of the last received message) so that the server resumes from where the client left off without duplicating messages +9. THE SSE endpoint SHALL assign a unique `id` field to each event (using the CWMP_Message sequence number) to support the `Last-Event-ID` reconnection mechanism +10. WHEN the Sequence_Viewer is closed or the user navigates away from the DeviceDetail page, THE Sequence_Viewer SHALL close the EventSource connection to free server resources. THE SSE endpoint SHALL also implement server-side connection timeouts: inactivity timeout of 5 minutes (no new exchanges pushed) and absolute timeout of 2 hours, to free resources for abandoned connections. WHEN a server-side timeout occurs, THE server SHALL close the connection gracefully, triggering client-side reconnection +11. IF the device is not currently being traced, THEN THE SSE endpoint SHALL return a 404 status and close the connection immediately + +### Requirement 7: Performance et pagination + +**User Story:** As an operator working with devices that generate hundreds of exchanges, I want the viewer to remain responsive, so that I can navigate through large sessions without lag. + +#### Acceptance Criteria + +1. THE Sequence_Viewer SHALL implement virtual scrolling or pagination to handle sessions with more than 200 messages while maintaining a frame render time of 100 milliseconds or less +2. IF the session contains more than 200 messages, THEN THE Sequence_Viewer SHALL load messages in batches of 50, display the most recent batch first, and provide scroll-based or button-based navigation to access older messages +3. THE Exchange_Store SHALL support a TTL (time-to-live) mechanism that automatically removes exchanges older than 24 hours to prevent unbounded storage growth +4. IF an exchange retrieval request elapsed time exceeds 5 seconds, THEN THE Exchange_API SHALL return a partial result containing the most recent messages in reverse-chronological order, along with an `isTruncated: true` flag and the total number of available messages + +### Requirement 8: Purge et gestion des échanges + +**User Story:** As an operator, I want to clear the exchange history for a device, so that I can start a fresh observation session. + +#### Acceptance Criteria + +1. WHILE a traced device is selected in the log viewer, THE Sequence_Viewer SHALL display a "Clear exchanges" button that deletes all stored server-side exchange logs for that device +2. WHEN the user clicks the "Clear exchanges" button, THE Sequence_Viewer SHALL display a confirmation dialog requesting explicit acceptance before proceeding with deletion +3. IF the user cancels the confirmation dialog, THEN THE Sequence_Viewer SHALL close the dialog and leave the existing exchange log unchanged +4. WHEN the user confirms the deletion AND the server returns a successful response (204), THE Sequence_Viewer SHALL: + - Verify the SSE connection remains active + - Reset the log panel to an empty state displaying a "No log entries yet" message + - Continue receiving new messages via the active SSE connection + - IF SSE becomes inactive during or after the clear operation, attempt automatic reconnection before resuming +5. IF the server fails to clear the exchanges, THEN THE Sequence_Viewer SHALL display an error message indicating the failure and preserve the current exchange log content diff --git a/.kiro/specs/tr069-exchange-viewer/tasks.md b/.kiro/specs/tr069-exchange-viewer/tasks.md new file mode 100644 index 0000000..19befac --- /dev/null +++ b/.kiro/specs/tr069-exchange-viewer/tasks.md @@ -0,0 +1,531 @@ +# Implementation Plan: TR-069 Exchange Viewer + +## Overview + +This implementation plan converts the TR-069 Exchange Viewer design into incremental coding tasks. The feature replaces the file-based CWMP trace system with structured MongoDB storage, real-time SSE streaming, and an interactive sequence diagram UI. Tasks follow the phased approach (MVP → Robustness → Optimization) and build on each other progressively. + +## Tasks + +- [x] 0. Database Setup + - [x] 0.1 Create MongoDB migration for cwmp_exchanges collection + - Create migration file `database/migrations/XXXX_create_cwmp_exchanges.ts` + - Create `cwmp_exchanges` collection and `cwmp_exchange_counters` collection + - Create all 3 indexes: compound (deviceId, timestamp), unique (deviceId, sequenceNumber), TTL (createdAt, 86400s) + - Implement rollback logic (`down()` method) to drop both collections + - _Requirements: 1.1, 1.5, 7.3_ + - [x] 0.2 Create database seeding script for testing + - Create `database/seeders/cwmp_exchanges_seeder.ts` + - Generate 100-500 sample exchanges with realistic CWMP methods (Inform, GetParameterValues, SetParameterValues, etc.) + - Include varied directions, sessionIds, timestamps spanning 24h, and realistic XML bodies + - Support configurable device count and exchange count per device + - _For integration/manual testing_ + - [x] 0.3 Verify MongoDB indexes after app startup + - Add health check logic in app startup to verify cwmp_exchanges indexes exist + - Log warnings if any expected index is missing + - Include index verification in the health check endpoint response + - _Requirements: 1.5_ + +- [x] 1. Set up configuration and data model foundations + - [x] 1.1 Create Exchange Store configuration file + - Create `config/exchange_store.ts` with all configurable values (maxMessagesPerDevice, TTL, SSE timeouts, retry policy, feature flags) + - Use `env.get()` pattern consistent with existing AdonisJS configs + - _Requirements: 1.4, 1.7, 6.10, 7.3_ + - [x] 1.2 Create Exchange MongoDB model + - Create `app/models/exchange.ts` with the `CwmpExchange` interface and `ExchangeModel` class + - Implement static methods: `findByDevice`, `findAfterSequence`, `deleteByDevice`, `getMaxSequenceNumber`, `countByDevice`, `deleteOldestBeyondCap`, `hasExchanges`, `getMetadata` + - Implement `ensureIndexes()` for (deviceId, timestamp), (deviceId, sequenceNumber) unique, and TTL on createdAt + - Follow existing model patterns from `app/models/device.ts` + - _Requirements: 1.1, 1.5, 7.3_ + +- [x] 2. Implement ExchangeStoreService (core backend logic) + - [x] 2.1 Create ExchangeStoreService with CWMP method extraction and persistence + - Create `app/services/exchange_store_service.ts` as a singleton service + - Implement `extractCwmpMethod(xml)`: parse first `cwmp:` namespace child element, return "Empty" for empty/whitespace, "Unknown" for no cwmp element + - Implement `persistExchange(options)`: extract method, get sequence number, persist to MongoDB, emit event via EventEmitter + - Implement `getNextSequenceNumber(deviceId)`: atomic increment via `cwmp_exchange_counters` collection using `findOneAndUpdate` with upsert + - Implement `subscribe(deviceId, listener)` and `unsubscribe(deviceId, listener)` for EventEmitter-based pub/sub + - _Requirements: 1.1, 1.2, 1.3, 1.6_ + - [x] 2.2 Write property test: CWMP Method Extraction (Property 1) + - **Property 1: CWMP Method Extraction** + - Test with random SOAP XML bodies: valid cwmp namespace → extract method name, empty XML → "Empty", no cwmp namespace → "Unknown" + - **Validates: Requirements 1.2, 1.3, 1.6** + - **Acceptance Criteria:** + - ✅ 100+ iterations with fast-check + - ✅ All edge cases covered (empty, malformed, valid cwmp namespace, multiple namespaces) + - ✅ Test file: `tests/unit/services/exchange_store.property.spec.ts` + - [x] 2.3 Write property test: Sequence Number Monotonicity (Property 2) + - **Property 2: Sequence Number Monotonicity** + - Test with random series of insertions for a device: sequence numbers are strictly monotonically increasing (each = previous + 1) + - **Validates: Requirements 1.1** + - **Acceptance Criteria:** + - ✅ 100+ iterations with fast-check + - ✅ All edge cases covered (concurrent insertions, multiple devices, reset scenarios) + - ✅ Test file: `tests/unit/services/exchange_store.property.spec.ts` + - [x] 2.4 Implement message cap enforcement + - Implement `enforceMessageCap(deviceId)`: delete oldest messages when count exceeds 5000 + - Call cap enforcement after each persist + - _Requirements: 1.4_ + - [x] 2.5 Write property test: Message Cap Enforcement (Property 3) + - **Property 3: Message Cap Enforcement** + - Test with random number of insertions: count never exceeds 5000, retained messages are the most recent ones + - **Validates: Requirements 1.4** + - **Acceptance Criteria:** + - ✅ 100+ iterations with fast-check + - ✅ All edge cases covered (exact boundary 5000, 5001, bulk inserts) + - ✅ Test file: `tests/unit/services/exchange_store.property.spec.ts` + - [x] 2.6 Implement retry logic for critical messages + - Implement `persistWithRetry(doc, isCritical)`: classify message by cwmpMethod (critical vs non-critical), apply 3 retries with exponential backoff (1s, 2s, 4s) for critical, discard immediately for non-critical + - Ensure CWMP session is never interrupted regardless of persistence outcome + - _Requirements: 1.7_ + - [x] 2.7 Write property test: Retry Classification Correctness (Property 4) + - **Property 4: Retry Classification Correctness** + - Test with random CWMP methods: critical methods → 3 retries with backoff, non-critical → 0 retries + - **Validates: Requirements 1.7** + - **Acceptance Criteria:** + - ✅ 100+ iterations with fast-check + - ✅ All edge cases covered (all critical methods, all non-critical methods, unknown methods) + - ✅ Test file: `tests/unit/services/exchange_store.property.spec.ts` + +- [x] 3. Checkpoint - Core service verified + - Ensure all tests pass, ask the user if questions arise. + - **Success Criteria:** + - ✅ All 4 property tests pass (100+ iterations each) + - ✅ Unit test coverage >85% for ExchangeStoreService + - ✅ Manual verification: sequence numbers increment correctly + - ✅ Manual verification: message cap enforcement at 5000 + - ✅ Manual verification: retry logic with simulated MongoDB failure + +- [x] 4. Implement Exchange API Controller + - [x] 4.1 Create Exchange Controller with GET and DELETE endpoints + - Create `app/controllers/exchange_controller.ts` + - Implement `index()`: GET exchanges with `limit` (default 200, max 1000) and `after` query params, validate inputs, return ordered by timestamp ascending, include metadata (traceStatus, recordedFrom, recordedUntil, total) + - Implement `destroy()`: DELETE all exchanges for a device, return 204 + - Handle edge cases: 404 for never-traced device, 200 empty array for traced with no messages, 400 for invalid params + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 2.9_ + - [x] 4.2 Write property test: API Result Ordering (Property 5) + - **Property 5: API Result Ordering** + - Test with random sets of persisted exchanges: returned array ordered by timestamp ascending with no inversions + - **Validates: Requirements 2.1** + - **Acceptance Criteria:** + - ✅ 100+ iterations with fast-check + - ✅ All edge cases covered (empty set, single item, duplicates, boundary timestamps) + - ✅ Test file: `tests/unit/controllers/exchange_controller.property.spec.ts` + - [x] 4.3 Write property test: Limit Parameter Validation (Property 6) + - **Property 6: Limit Parameter Validation and Capping** + - Test with random limit values: [1,1000] → at most `limit` messages, omitted → 200, non-numeric or <1 → 400 + - **Validates: Requirements 2.2, 2.3** + - **Acceptance Criteria:** + - ✅ 100+ iterations with fast-check + - ✅ All edge cases covered (0, -1, 1001, NaN, float, string) + - ✅ Test file: `tests/unit/controllers/exchange_controller.property.spec.ts` + - [x] 4.4 Write property test: After Parameter Filtering (Property 7) + - **Property 7: After Parameter Filtering** + - Test with random ISO 8601 timestamps: all returned exchanges have timestamp > specified value, invalid timestamp → 400 + - **Validates: Requirements 2.4, 2.5** + - **Acceptance Criteria:** + - ✅ 100+ iterations with fast-check + - ✅ All edge cases covered (future dates, epoch, invalid strings, partial ISO) + - ✅ Test file: `tests/unit/controllers/exchange_controller.property.spec.ts` + - [x] 4.5 Implement SSE stream endpoint + - Implement `stream()`: SSE endpoint at GET `/api/v1/tr069-trace/:deviceId/exchanges/stream` + - Set SSE headers (Content-Type, Cache-Control, Connection, X-Accel-Buffering) + - Handle Last-Event-ID for reconnection: query exchanges with sequenceNumber > lastEventId + - Subscribe to ExchangeStoreService events, push new exchanges as SSE events with `id: sequenceNumber` + - Implement server-side timeouts: 5min inactivity, 2h absolute + - Return 404 if device is not traced + - _Requirements: 6.1, 6.8, 6.9, 6.10, 6.11_ + - [x] 4.6 Write property test: SSE Event ID Consistency (Property 13) + - **Property 13: SSE Event ID Consistency** + - Test with random exchanges: event `id` field always equals the exchange's `sequenceNumber` + - **Validates: Requirements 6.9** + - **Acceptance Criteria:** + - ✅ 100+ iterations with fast-check + - ✅ All edge cases covered (first message, large sequence numbers, gaps) + - ✅ Test file: `tests/unit/controllers/exchange_controller.property.spec.ts` + - [x] 4.7 Write property test: SSE Reconnection Continuity (Property 14) + - **Property 14: SSE Reconnection Continuity (Last-Event-ID)** + - Test with random disconnect points: reconnection with Last-Event-ID=N resumes only exchanges with seq > N, zero duplicates + - **Validates: Requirements 6.8** + - **Acceptance Criteria:** + - ✅ 100+ iterations with fast-check + - ✅ All edge cases covered (Last-Event-ID=0, very large ID, non-existent ID) + - ✅ Test file: `tests/unit/controllers/exchange_controller.property.spec.ts` + +- [x] 5. Modify existing code for integration + - [x] 5.1 Modify CwmpLoggerMiddleware for dual-write + - Update `app/middleware/cwmp_logger_middleware.ts` to call `ExchangeStoreService.persistExchange()` in addition to existing file-based writes (dual-write for Phase 1 safety) + - Remove prettyXml formatting for the new system (store raw XML) + - Keep brief app-level logging for non-traced devices + - Ensure middleware never blocks CWMP processing (fire-and-forget with error handling) + - _Requirements: 1.1, 1.7_ + - [x] 5.2 Register Exchange API routes + - Add routes to `start/routes.ts` within the `/api/v1` group: + - `GET /tr069-trace/:deviceId/exchanges` → ExchangeController.index + - `DELETE /tr069-trace/:deviceId/exchanges` → ExchangeController.destroy + - `GET /tr069-trace/:deviceId/exchanges/stream` → ExchangeController.stream + - _Requirements: 2.1, 2.9, 6.1_ + +- [x] 6. Checkpoint - Backend complete + - Ensure all tests pass, ask the user if questions arise. + - **Success Criteria:** + - ✅ All API property tests pass (Properties 5, 6, 7, 13, 14) + - ✅ Unit test coverage >85% for ExchangeController + - ✅ Manual verification: GET /exchanges returns ordered results + - ✅ Manual verification: DELETE /exchanges returns 204 + - ✅ Manual verification: SSE stream connects and pushes events + - ✅ Manual verification: Last-Event-ID reconnection works correctly + +- [x] 7. Integration Testing + - [x] 7.1 Full exchange lifecycle integration test + - Test complete flow: SOAP capture → persist to MongoDB → SSE event emitted → frontend receives + - Verify data integrity at each step (deviceId, direction, cwmpMethod, rawXml preserved) + - Test file: `tests/integration/exchange_lifecycle.spec.ts` + - _Validates: Requirements 1.1, 1.7, 6.1, 6.2_ + - [x] 7.2 SSE reconnection with Last-Event-ID integration test + - Connect to SSE stream, receive 5 messages, simulate disconnect + - Reconnect with Last-Event-ID set to sequence number of last received message + - Verify: no duplicates received, messages resume from correct point + - Test file: `tests/integration/sse_reconnection.spec.ts` + - _Validates: Requirements 6.8, 6.9_ + - [x] 7.3 Message cap enforcement integration test + - Insert 5001 messages for a single device + - Verify: oldest message deleted, total count = 5000 + - Verify: retained messages are the most recent by timestamp + - Test file: `tests/integration/message_cap.spec.ts` + - _Validates: Requirements 1.4_ + - [x] 7.4 TTL expiration integration test + - Insert exchanges with `createdAt` set to 25 hours ago + - Verify: MongoDB TTL index auto-removes expired documents + - Note: requires waiting for MongoDB TTL monitor (runs every 60s) or use test helper + - Test file: `tests/integration/ttl_expiration.spec.ts` + - _Validates: Requirements 7.3_ + - [x] 7.5 Clear exchanges flow integration test + - Create exchanges for a device, verify they exist + - Call DELETE endpoint, verify 204 response + - Verify: exchange list is empty + - Verify: SSE stream remains active and continues receiving new messages + - Test file: `tests/integration/clear_exchanges.spec.ts` + - _Validates: Requirements 8.1, 8.2, 8.3, 8.4, 8.5_ + +- [x] 8. Implement frontend composable and components + - [x] 8.1 Create useExchangeStream composable + - Create `inertia/composables/useExchangeStream.ts` + - Implement EventSource connection management (connect, disconnect) + - Manage reactive state: messages array, connectionState ('connected'|'paused'|'disconnected'), isAutoScrollEnabled + - Handle SSE events: parse JSON, append to messages, manage auto-scroll + - Handle reconnection: EventSource built-in retry (3s), preserve Last-Event-ID + - Implement clearMessages: call DELETE API, reset state + - Implement fallback to REST polling (5s interval) if SSE unavailable + - _Requirements: 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8, 8.4_ + - [x] 8.2 Create SequenceViewer.vue component + - Create `inertia/components/SequenceViewer.vue` + - Render CPE and ACS vertical lifelines + - Render message arrows: left-to-right for CPE→ACS, right-to-left for ACS→CPE + - Display cwmpMethod label (truncated at 40 chars with tooltip) and timestamp (HH:mm:ss.SSS) + - Handle message selection (highlight row, emit to Inspector Panel) + - Display connection state indicator (connected/paused/disconnected) + - Display "Resume auto-scroll" button when paused + - Display session header (device identifier, manufacturer, productClass, serialNumber, trace start time) + - Display empty state placeholder when no messages (without lifelines) + - Display "Clear exchanges" button with confirmation dialog + - Use Boosted CSS framework classes and Orange design tokens + - _Requirements: 3.1, 3.4, 3.5, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8, 6.3, 6.4, 6.5, 6.6, 8.1, 8.2, 8.3, 8.4, 8.5_ + - [x] 8.3 Write property test: Arrow Direction Correctness (Property 9) + - **Property 9: Arrow Direction Correctness** + - Test with random lists of messages with mixed directions: CPE→ACS arrows point left-to-right, ACS→CPE point right-to-left, ordered by timestamp ascending + - **Validates: Requirements 4.2** + - **Acceptance Criteria:** + - ✅ 100+ iterations with fast-check + - ✅ All edge cases covered (all same direction, alternating, single message) + - ✅ Test file: `tests/unit/components/sequence_viewer.property.spec.ts` + - [x] 8.4 Write property test: Method Label Truncation (Property 10) + - **Property 10: Method Label Truncation** + - Test with random method names: >40 chars → truncated + tooltip, ≤40 chars → full name displayed + - **Validates: Requirements 4.3** + - **Acceptance Criteria:** + - ✅ 100+ iterations with fast-check + - ✅ All edge cases covered (exactly 40 chars, 41 chars, empty string, unicode) + - ✅ Test file: `tests/unit/components/sequence_viewer.property.spec.ts` + - [x] 8.5 Write property test: Timestamp Formatting (Property 11) + - **Property 11: Timestamp Formatting** + - Test with random valid ISO 8601 timestamps: formatted display matches `HH:mm:ss.SSS` pattern + - **Validates: Requirements 4.4** + - **Acceptance Criteria:** + - ✅ 100+ iterations with fast-check + - ✅ All edge cases covered (midnight, noon, millisecond precision, timezone offsets) + - ✅ Test file: `tests/unit/components/sequence_viewer.property.spec.ts` + - [x] 8.6 Create InspectorPanel.vue component + - Create `inertia/components/InspectorPanel.vue` + - Display formatted XML with 2-space indentation and syntax highlighting (elements, attributes, values in distinct colors) + - Display header showing cwmpMethod + direction + - Implement "Copy" button: copy raw XML to clipboard, show confirmation for 2s + - Implement resizable panel (min 300px, max 70% viewport) + - Display placeholder when no message selected + - Handle empty/unparseable XML gracefully + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6_ + - [x] 8.7 Write property test: Inspector Panel Display (Property 12) + - **Property 12: Inspector Panel Display on Selection** + - Test with random messages with non-empty rawXml: panel displays formatted XML with 2-space indentation AND header with cwmpMethod + direction + - **Validates: Requirements 5.1, 5.2** + - **Acceptance Criteria:** + - ✅ 100+ iterations with fast-check + - ✅ All edge cases covered (empty XML, malformed XML, large XML, special characters) + - ✅ Test file: `tests/unit/components/inspector_panel.property.spec.ts` + +- [x] 9. Integrate into DeviceDetail page + - [x] 9.1 Modify DeviceDetail.vue to add Sequence Viewer section + - Add collapsible "Échanges TR-069" section to `inertia/pages/DeviceDetail.vue`, positioned after the Actions section + - Show section only when device is traced, with SequenceViewer + InspectorPanel + - Show enable/disable tracing button in Actions section + - Pass device props (identifier, manufacturer, productClass, serialNumber) and traceStartedAt to SequenceViewer + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_ + +- [x] 10. Checkpoint - MVP complete (Phase 1) + - Ensure all tests pass, ask the user if questions arise. + - **Success Criteria:** + - ✅ Component property tests pass (Properties 9, 10, 11, 12) + - ✅ SequenceViewer renders correctly with sample data + - ✅ Full integration test passes (SOAP → MongoDB → SSE → UI) + - ✅ Manual real-time test: trace device, trigger Inform, see exchange appear live + +- [x] 11. Phase 2 — Robustness enhancements + - [x] 11.1 Implement virtual scrolling for large sessions + - Add virtual scrolling to SequenceViewer.vue for sessions >200 messages + - Use fixed item height (60px) with buffer of 5 items + - Maintain frame render time ≤100ms + - _Requirements: 7.1_ + - [x] 11.2 Implement lazy loading in batches of 50 + - Load messages in batches of 50 using IntersectionObserver + - Display most recent batch first, load older on scroll + - _Requirements: 7.2_ + - [x] 11.3 Implement memory management (max 1000 cached messages) + - Cap in-memory message cache to 1000 most recent messages + - Discard oldest messages when cap is exceeded on client side + - _Requirements: 7.1_ + - [x] 11.4 Add read timeout handling (5s partial results) + - In Exchange Controller index(), return partial results with `isTruncated: true` flag if query exceeds 5 seconds + - Include total count in response for client awareness + - _Requirements: 7.4_ + +- [x] 12. Checkpoint - Robustness complete + - Ensure all tests pass, ask the user if questions arise. + - **Success Criteria:** + - ✅ Virtual scroll maintains 60FPS at 5000 messages + - ✅ Lazy loading works with IntersectionObserver + - ✅ Memory cap enforced at 1000 cached messages client-side + - ✅ Read timeout returns partial results correctly + +- [ ] 13. Documentation + - [ ] 13.1 Write API documentation (OpenAPI/Swagger) + - Document all 3 endpoints: GET /exchanges, DELETE /exchanges, GET /exchanges/stream + - Include request/response examples for each endpoint + - Document all error codes (400, 404, 204) with descriptions + - Document query parameters (limit, after) with valid ranges + - Document SSE event format and reconnection protocol + - File: `docs/api/tr069-exchanges.yaml` or inline AdonisJS Swagger annotations + - [ ] 13.2 Write deployment guide + - Document MongoDB setup requirements (collection, indexes, replica set not required) + - Document environment variables (FEATURE_EXCHANGE_PERSISTENCE, FEATURE_SSE_ENABLED, FEATURE_EXCHANGE_API_SOURCE) + - Document feature flag usage and toggle procedures + - Document health check endpoint and monitoring + - File: `docs/deployment/tr069-exchange-viewer.md` + - [ ] 13.3 Write troubleshooting guide + - Document common issues: SSE disconnections (causes, solutions) + - Document MongoDB timeout scenarios and recovery + - Document message loss diagnosis (check logs, verify retry, check cap) + - Document how to verify indexes and TTL status + - File: `docs/troubleshooting/tr069-exchange-viewer.md` + - [x] 13.4 Write migration guide + - Document data migration script from file-based to MongoDB + - Document dual-write phase (Phase 1) and how to verify consistency + - Document cutover procedure (Phase 2 → Phase 3) + - Document rollback procedure at each phase + - File: `docs/migration/tr069-exchange-viewer.md` + +- [ ] 14. Monitoring & Observability + - [ ] 14.1 Implement health check endpoint + - Create `GET /api/v1/health/tr069-exchanges` endpoint + - Check MongoDB connectivity and latency + - Report active SSE connection count + - Report collection stats (document count, size) + - Return structured JSON response with status healthy/degraded/unhealthy + - _Requirements: 1.5_ + - [ ] 14.2 Add structured logging + - Log all persistence operations (success/failure) with deviceId, cwmpMethod, duration + - Log SSE connection events (connect, disconnect, reconnect) with deviceId + - Log API requests with method, path, status code, response time + - Use JSON format compatible with ELK/Datadog ingestion + - Include correlation IDs for tracing across components + - [ ] 14.3 Implement performance metrics + - Track MongoDB write latency: p50, p95, p99 + - Track SSE push latency (time from persist to client receive) + - Track API response times by endpoint + - Expose metrics in Prometheus format at `/metrics` or via structured logs + - [ ] 14.4 Configure alerting rules + - Alert: SSE active connections > 100 (high load) + - Alert: MongoDB write latency p95 > 100ms (performance degradation) + - Alert: Message loss rate > 0.1% (critical failures) + - Alert: TTL cleanup lag > 1 hour (index issue) + - Document thresholds and escalation procedures + +- [ ] 15. Final checkpoint - All phases complete + - Ensure all tests pass, ask the user if questions arise. + - **Success Criteria:** + - ✅ Virtual scroll 60FPS at 5000 messages + - ✅ Lazy loading works correctly + - ✅ Memory cap enforced client-side + - ✅ Documentation complete (API, deployment, troubleshooting, migration) + - ✅ Monitoring configured (health check, logging, metrics, alerts) + - ✅ All integration tests pass + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests validate universal correctness properties from the design document +- Unit tests validate specific examples and edge cases +- Phase 1 (tasks 0-10) delivers a working MVP with dual-write safety +- Phase 2 (tasks 11-12) adds robustness for production load +- Phase 3 (tasks 13-14) adds documentation and observability +- The dual-write approach in task 5.1 ensures rollback safety: old file-based system continues until new system is validated +- All frontend components use Boosted (Orange CSS) framework — no additional CSS framework needed + +## Testing Strategy + +### Test Pyramid + +| Level | Count | Description | +|-------|-------|-------------| +| Property Tests | 14 | Universal correctness properties (fast-check, 100+ iterations) | +| Unit Tests | 40+ | Example-based tests for specific behaviors and edge cases | +| Integration Tests | 6 | End-to-end flow validation (lifecycle, SSE, cap, TTL, clear, reconnection) | + +### Test Coverage Goals + +| Type | Target | Rationale | +|------|--------|-----------| +| Unit Tests | 85%+ | Core services and controllers must be well-covered | +| Integration Tests | 80%+ | Critical flows must be validated end-to-end | +| E2E Tests | 60%+ (future) | UI flows with real browser — deferred to post-MVP | + +### Running Tests + +```bash +# Run all tests +npm run test + +# Run unit tests only +npm run test:unit + +# Run integration tests only +npm run test:integration + +# Run tests in watch mode (development) +npm run test:watch + +# Run with coverage report +npm run test:coverage +``` + +## Rollback & Contingency Plan + +### Phase 1 Rollback (Persistence & SSE) + +| Feature Flag | Effect | +|--------------|--------| +| `FEATURE_EXCHANGE_PERSISTENCE=false` | Disables MongoDB writes; CWMP sessions continue unaffected | +| `FEATURE_SSE_ENABLED=false` | Disables SSE streaming; frontend falls back to REST polling (5s) | + +### Phase 2 Rollback (API Source) + +| Feature Flag | Effect | +|--------------|--------| +| `FEATURE_EXCHANGE_API_SOURCE=file` | Frontend reads from old file-based system instead of MongoDB | + +Fallback logic in `DeviceDetail.vue`: +```typescript +const exchanges = await api.getExchanges(deviceId) + .catch(() => legacyService.readExchanges(deviceId)) +``` + +### Data Consistency Checks + +- Compare file-based exchange count vs MongoDB count for same device +- Alert if discrepancy > 5% over a 1-hour window +- During dual-write phase, periodically verify both systems agree on latest messages + +## Known Risks & Mitigations + +| # | Risk | Impact | Mitigation | Related Tasks | +|---|------|--------|------------|---------------| +| 1 | MongoDB write latency > 100ms | Messages queued, potential lag | Write batching, monitor p95, alert at threshold | 14.3, 14.4 | +| 2 | SSE connection limit (100+ concurrent) | Server resource exhaustion | Connection pooling, rate limiting per device (max 5) | 4.5, 14.4 | +| 3 | Message loss during MongoDB failure | Gaps in exchange history | Retry logic (3x for critical), dual-write phase | 2.6, 5.1 | +| 4 | Memory leak in EventEmitter | Gradual server degradation | Listener cleanup on disconnect, connection timeouts (5min/2h) | 4.5, 8.1 | +| 5 | Large XML payloads (>2MB) | Storage bloat, slow queries | Truncation at 2MB, monitoring, alert on frequent truncation | 2.1, 14.2 | +| 6 | Browser memory with 1000+ messages | UI freeze, tab crash | Virtual scrolling, client-side cap at 1000 messages | 11.1, 11.3 | +| 7 | SSE reconnection storm (many clients) | Server overload | Exponential backoff (3s base), max retry limit | 8.1, 7.2 | +| 8 | Concurrent clear operations | Race condition, data corruption | Optimistic locking or queue-based deletion | 7.5, 4.1 | + +### Mitigation Checklist + +- [ ] Write batching implemented (Risk 1) → Task 2.1 +- [ ] SSE connection limit enforced (Risk 2) → Task 4.5 +- [ ] Retry logic tested (Risk 3) → Task 2.6, 2.7 +- [ ] Listener cleanup verified (Risk 4) → Task 4.5 +- [ ] 2MB truncation with logging (Risk 5) → Task 2.1 +- [ ] Virtual scrolling + memory cap (Risk 6) → Tasks 11.1, 11.3 +- [ ] Reconnection backoff configured (Risk 7) → Task 8.1 +- [ ] Concurrent deletion handled (Risk 8) → Task 4.1 + +## Task Dependency Graph + +### Wave Visualization + +``` +Wave 0: [0.1] [0.2] [0.3] ← Database setup +Wave 1: [1.1] [1.2] ← Config & models +Wave 2: [2.1] [2.4] [2.6] ← Core service impl +Wave 3: [2.2*] [2.3*] [2.5*] [2.7*] ← Core property tests +Wave 4: [4.1] [4.5] [5.1] [5.2] ← API & integration +Wave 5: [4.2*] [4.3*] [4.4*] [4.6*] [4.7*] [7.1-7.5] ← API tests & integration tests +Wave 6: [8.1] [8.2] [8.6] ← Frontend components +Wave 7: [8.3*] [8.4*] [8.5*] [8.7*] [9.1] ← Frontend tests & DeviceDetail +Wave 8: [11.1] [11.2] [11.3] [11.4] ← Robustness +Wave 9: [13.1] [13.2] [13.3] [13.4] [14.1] [14.2] [14.3] [14.4] ← Docs & monitoring +``` + +### Estimated Timeline + +| Wave | Tasks | Estimated Effort | Duration | +|------|-------|-----------------|----------| +| Wave 0 | 0.1, 0.2, 0.3 | 4h | 1 day | +| Wave 1 | 1.1, 1.2 | 3h | 1 day | +| Wave 2 | 2.1, 2.4, 2.6 | 8h | 2 days | +| Wave 3 | 2.2*, 2.3*, 2.5*, 2.7* | 4h | 1 day | +| Wave 4 | 4.1, 4.5, 5.1, 5.2 | 10h | 2-3 days | +| Wave 5 | 4.2*-4.7*, 7.1-7.5 | 12h | 3 days | +| Wave 6 | 8.1, 8.2, 8.6 | 10h | 2-3 days | +| Wave 7 | 8.3*-8.7*, 9.1 | 6h | 1-2 days | +| **MVP Total** | | **~57h** | **~2 weeks** | +| Wave 8 | 11.1-11.4 | 6h | 1-2 days | +| Wave 9 | 13.1-13.4, 14.1-14.4 | 8h | 2 days | +| **Grand Total** | | **~71h** | **~3 weeks** | + +```json +{ + "waves": [ + { "id": 0, "tasks": ["0.1", "0.2", "0.3"] }, + { "id": 1, "tasks": ["1.1", "1.2"] }, + { "id": 2, "tasks": ["2.1", "2.4", "2.6"] }, + { "id": 3, "tasks": ["2.2", "2.3", "2.5", "2.7"] }, + { "id": 4, "tasks": ["4.1", "4.5", "5.1", "5.2"] }, + { "id": 5, "tasks": ["4.2", "4.3", "4.4", "4.6", "4.7", "7.1", "7.2", "7.3", "7.4", "7.5"] }, + { "id": 6, "tasks": ["8.1", "8.2", "8.6"] }, + { "id": 7, "tasks": ["8.3", "8.4", "8.5", "8.7", "9.1"] }, + { "id": 8, "tasks": ["11.1", "11.2", "11.3", "11.4"] }, + { "id": 9, "tasks": ["13.1", "13.2", "13.3", "13.4", "14.1", "14.2", "14.3", "14.4"] } + ] +} +``` diff --git a/app/controllers/exchange_controller.ts b/app/controllers/exchange_controller.ts new file mode 100644 index 0000000..7cee719 --- /dev/null +++ b/app/controllers/exchange_controller.ts @@ -0,0 +1,381 @@ +import type { HttpContext } from '@adonisjs/core/http' +import logger from '@adonisjs/core/services/logger' +import ExchangeModel from '#models/exchange' +import type { CwmpExchange } from '#models/exchange' +import { Tr069TraceService } from '#services/tr069_trace_service' +import { ExchangeStoreService } from '#services/exchange_store_service' +import { ExchangeMetricsService } from '#services/exchange_metrics_service' +import exchangeStoreConfig from '#config/exchange_store' + +export default class ExchangeController { + /** + * GET /api/v1/tr069-trace/:deviceId/exchanges + * + * Returns exchanges for a device ordered by timestamp ascending. + * Includes metadata about trace status and recording window. + * + * Query params: + * - limit: number of results (default 200, max 1000) + * - after: ISO 8601 timestamp for cursor-based pagination (newer messages) + * - before: ISO 8601 timestamp for reverse pagination (older messages) + * - latest: if "true", returns the most recent `limit` messages (for initial load) + */ + async index({ params, request, response }: HttpContext) { + const startTime = performance.now() + const { deviceId } = params + + // Validate limit parameter + const limitParam = request.qs().limit + let limit = 200 + + if (limitParam !== undefined && limitParam !== '') { + const parsed = Number(limitParam) + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) { + return response.status(400).json({ + error: 'Invalid limit parameter. Must be an integer between 1 and 1000.', + }) + } + limit = Math.min(parsed, 1000) + } + + // Validate after parameter + const afterParam = request.qs().after as string | undefined + if (afterParam !== undefined && afterParam !== '') { + if (!this.isValidIso8601(afterParam)) { + return response.status(400).json({ + error: 'Invalid after parameter. Must be a valid ISO 8601 timestamp.', + }) + } + } + + // Validate before parameter (for loading older messages in batches) + const beforeParam = request.qs().before as string | undefined + if (beforeParam !== undefined && beforeParam !== '') { + if (!this.isValidIso8601(beforeParam)) { + return response.status(400).json({ + error: 'Invalid before parameter. Must be a valid ISO 8601 timestamp.', + }) + } + } + + // Check if the device has ever been traced (has any exchanges) + const traceService = Tr069TraceService.getInstance() + const isCurrentlyTraced = traceService.isTraced(deviceId) + const hasExchanges = await ExchangeModel.hasExchanges(deviceId) + + // Never-traced device: no exchanges exist and not currently traced + if (!hasExchanges && !isCurrentlyTraced) { + return response.status(404).json({ + error: `No trace exists for device ${deviceId}`, + }) + } + + // Fetch exchanges with read timeout (Requirement 7.4) + const latestParam = request.qs().latest as string | undefined + const useLatest = latestParam === 'true' + + const timeoutMs = Number(exchangeStoreConfig.readTimeoutMs) + let exchanges: CwmpExchange[] + let isTruncated = false + + try { + exchanges = await this.queryWithTimeout( + deviceId, + { limit, after: afterParam, before: beforeParam, latest: useLatest }, + timeoutMs + ) + } catch (error) { + if ((error as Error).message === 'QUERY_TIMEOUT') { + logger.warn( + { correlationId: request.id(), deviceId, timeoutMs }, + '[ExchangeController] Read timeout exceeded, returning partial results' + ) + isTruncated = true + exchanges = [] + } else { + throw error + } + } + + // Build metadata + const metadata = await ExchangeModel.getMetadata(deviceId) + + // Track API response time + const durationMs = performance.now() - startTime + ExchangeMetricsService.getInstance().recordApiResponseTime('GET /exchanges', durationMs) + + const statusCode = 200 + logger.info( + { + correlationId: request.id(), + method: 'GET', + path: `/api/v1/tr069-trace/${deviceId}/exchanges`, + statusCode, + durationMs: Math.round(durationMs * 100) / 100, + deviceId, + resultCount: exchanges.length, + isTruncated, + }, + '[ExchangeController] API request completed' + ) + + return response.status(statusCode).json({ + exchanges, + metadata: { + traceStatus: isCurrentlyTraced ? 'ACTIVE' : 'STOPPED', + traceStoppedAt: isCurrentlyTraced ? null : metadata.recordedUntil, + recordedFrom: metadata.recordedFrom, + recordedUntil: metadata.recordedUntil, + total: metadata.count, + ...(isTruncated ? { isTruncated: true } : {}), + }, + }) + } + + /** + * DELETE /api/v1/tr069-trace/:deviceId/exchanges + * + * Removes all stored exchanges for the device. + * Returns 204 with no body. + */ + async destroy({ params, request, response }: HttpContext) { + const startTime = performance.now() + const { deviceId } = params + await ExchangeModel.deleteByDevice(deviceId) + + // Track API response time + const durationMs = performance.now() - startTime + ExchangeMetricsService.getInstance().recordApiResponseTime('DELETE /exchanges', durationMs) + + logger.info( + { + correlationId: request.id(), + method: 'DELETE', + path: `/api/v1/tr069-trace/${deviceId}/exchanges`, + statusCode: 204, + durationMs: Math.round(durationMs * 100) / 100, + deviceId, + }, + '[ExchangeController] API request completed' + ) + + return response.status(204).send('') + } + + /** + * GET /api/v1/tr069-trace/:deviceId/exchanges/stream (SSE) + * + * SSE endpoint for real-time exchange streaming. + * - Sets SSE headers (Content-Type, Cache-Control, Connection, X-Accel-Buffering) + * - Handles Last-Event-ID for reconnection (resumes from sequenceNumber > lastEventId) + * - Subscribes to ExchangeStoreService events, pushes new exchanges as SSE events + * - Implements server-side timeouts: 5min inactivity, 2h absolute + * - Returns 404 if device is not traced + * - Returns 503 if SSE feature is disabled + * + * Requirements: 6.1, 6.8, 6.9, 6.10, 6.11 + */ + async stream({ params, request, response }: HttpContext) { + const startTime = performance.now() + const { deviceId } = params + + // Check SSE feature flag + if (!exchangeStoreConfig.featureFlags.sseEnabled) { + return response.status(503).json({ error: 'SSE streaming is currently disabled' }) + } + + // Requirement 6.11: Return 404 if device is not traced + const traceService = Tr069TraceService.getInstance() + if (!traceService.isTraced(deviceId)) { + return response.status(404).json({ error: 'Device is not being traced' }) + } + + const nodeRes = response.response + const nodeReq = request.request + + // Requirement 6.1: Set SSE headers + nodeRes.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + }) + + // Send retry interval so clients know how long to wait before reconnecting + nodeRes.write(`retry: ${exchangeStoreConfig.sse.retryIntervalMs}\n\n`) + + // Requirement 6.8: Handle Last-Event-ID for reconnection + const lastEventId = request.header('Last-Event-ID') + if (lastEventId) { + const seqNumber = Number(lastEventId) + if (!Number.isNaN(seqNumber)) { + try { + const missedExchanges = await ExchangeModel.findAfterSequence(deviceId, seqNumber) + for (const exchange of missedExchanges) { + this.sendSSEEvent(nodeRes, exchange) + } + if (missedExchanges.length > 0) { + logger.info( + { + correlationId: request.id(), + deviceId, + event: 'sse_reconnect_replay', + lastEventId: seqNumber, + replayedCount: missedExchanges.length, + }, + '[SSE] Replayed missed exchanges on reconnection' + ) + } + } catch (error) { + logger.error( + { + correlationId: request.id(), + deviceId, + event: 'sse_reconnect_error', + lastEventId, + error: (error as Error).message, + }, + '[SSE] Error fetching missed exchanges for reconnection' + ) + } + } + } + + // Subscribe to new events from ExchangeStoreService + const exchangeStore = ExchangeStoreService.getInstance() + let closed = false + + // Track SSE stream setup time + const setupDurationMs = performance.now() - startTime + ExchangeMetricsService.getInstance().recordApiResponseTime('GET /exchanges/stream', setupDurationMs) + + const listener = (exchange: any) => { + if (closed) return + this.sendSSEEvent(nodeRes, exchange) + resetInactivityTimer() + + // Track SSE push latency (time from persist/emit to client write) + if (exchange._emitTimestamp) { + const pushLatencyMs = performance.now() - exchange._emitTimestamp + ExchangeMetricsService.getInstance().recordSsePushLatency(pushLatencyMs) + } + } + + exchangeStore.subscribe(deviceId, listener) + + // Structured log: SSE connection established + const isReconnection = !!lastEventId + logger.info( + { + correlationId: request.id(), + deviceId, + event: isReconnection ? 'sse_reconnect' : 'sse_connect', + lastEventId: lastEventId || null, + setupDurationMs: Math.round(setupDurationMs * 100) / 100, + }, + `[SSE] Connection ${isReconnection ? 'reconnected' : 'established'}` + ) + + // Requirement 6.10: Timeout management + const inactivityTimeoutMs = exchangeStoreConfig.sse.inactivityTimeoutMs + const absoluteTimeoutMs = exchangeStoreConfig.sse.absoluteTimeoutMs + + let inactivityTimer: ReturnType = setTimeout( + () => close('inactivity'), + inactivityTimeoutMs + ) + const absoluteTimer: ReturnType = setTimeout( + () => close('absolute'), + absoluteTimeoutMs + ) + + function resetInactivityTimer() { + clearTimeout(inactivityTimer) + inactivityTimer = setTimeout(() => close('inactivity'), inactivityTimeoutMs) + } + + function close(reason?: string) { + if (closed) return + closed = true + + exchangeStore.unsubscribe(deviceId, listener) + clearTimeout(inactivityTimer) + clearTimeout(absoluteTimer) + + const connectionDurationMs = performance.now() - startTime + logger.info( + { + correlationId: request.id(), + deviceId, + event: 'sse_disconnect', + reason: reason || 'unknown', + connectionDurationMs: Math.round(connectionDurationMs * 100) / 100, + }, + '[SSE] Connection closed' + ) + + try { + nodeRes.end() + } catch { + // Response may already be closed + } + } + + // Client disconnect + nodeReq.on('close', () => close('client_disconnect')) + } + + /** + * Send a single SSE event to the client. + * Format: + * id: + * data: + * + * Requirement 6.9: Event id equals the exchange's sequenceNumber + */ + private sendSSEEvent( + res: import('node:http').ServerResponse, + exchange: any + ): void { + const data = JSON.stringify(exchange) + res.write(`id: ${exchange.sequenceNumber}\ndata: ${data}\n\n`) + } + + /** + * Validates that a string is a valid ISO 8601 timestamp. + * Checks that it parses to a valid Date and has the expected format patterns. + */ + private isValidIso8601(value: string): boolean { + const date = new Date(value) + if (isNaN(date.getTime())) { + return false + } + + // Must match common ISO 8601 patterns: + // YYYY-MM-DD, YYYY-MM-DDTHH:mm:ss, YYYY-MM-DDTHH:mm:ssZ, YYYY-MM-DDTHH:mm:ss.sssZ, etc. + const iso8601Pattern = + /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z|[+-]\d{2}:?\d{2})?)?$/ + return iso8601Pattern.test(value) + } + + /** + * Execute a findByDevice query with a timeout. + * If the query exceeds the timeout, throws an error with message 'QUERY_TIMEOUT'. + * This supports lazy loading with `before`/`latest` parameters for batch loading. + * + * Requirements: 7.4 (partial results on timeout) + */ + private async queryWithTimeout( + deviceId: string, + options: { limit: number; after?: string; before?: string; latest?: boolean }, + timeoutMs: number + ): Promise { + const queryPromise = ExchangeModel.findByDevice(deviceId, options) + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('QUERY_TIMEOUT')), timeoutMs) + }) + + return Promise.race([queryPromise, timeoutPromise]) + } +} diff --git a/app/controllers/health_controller.ts b/app/controllers/health_controller.ts index d995ea3..cd4fa8f 100644 --- a/app/controllers/health_controller.ts +++ b/app/controllers/health_controller.ts @@ -1,5 +1,7 @@ import type { HttpContext } from '@adonisjs/core/http' import { getMongoDb } from '#providers/mongodb_provider' +import { verifyCwmpExchangesIndexes } from '#services/exchange_index_checker' +import { ExchangeStoreService } from '#services/exchange_store_service' export default class HealthController { async checklife({ response }: HttpContext) { @@ -10,9 +12,117 @@ export default class HealthController { try { const db = getMongoDb() await db.command({ ping: 1 }) - return response.json({ status: 'OK', version: '1.0.0' }) + + const indexCheck = await verifyCwmpExchangesIndexes(db).catch(() => null) + + return response.json({ + status: 'OK', + version: '1.0.0', + indexes: indexCheck + ? { + cwmp_exchanges: { + allPresent: indexCheck.allPresent, + indexes: indexCheck.indexes, + }, + } + : undefined, + }) } catch { return response.status(503).json({ status: 'KO', version: '1.0.0' }) } } + + /** + * GET /api/v1/health/tr069-exchanges + * + * Health check endpoint for the TR-069 exchange viewer subsystem. + * Reports: + * - MongoDB connectivity and latency + * - Active SSE connection count + * - Collection stats (document count, size) + * - Overall status: healthy / degraded / unhealthy + * + * Status logic: + * - healthy: MongoDB connected, latency < 100ms, indexes present + * - degraded: MongoDB connected but latency >= 100ms, or missing indexes + * - unhealthy: MongoDB unreachable or ping fails + */ + async checkTr069Exchanges({ response }: HttpContext) { + const timestamp = new Date().toISOString() + let mongoConnected = false + let latencyMs = -1 + let collectionStats: { count: number; sizeMb: number } | null = null + let indexCheck: Awaited> | null = null + + // Check MongoDB connectivity and measure latency + try { + const db = getMongoDb() + const start = Date.now() + await db.command({ ping: 1 }) + latencyMs = Date.now() - start + mongoConnected = true + + // Get collection stats + const collection = db.collection('cwmp_exchanges') + const count = await collection.estimatedDocumentCount() + + let sizeMb = 0 + try { + const stats = await db.command({ collStats: 'cwmp_exchanges' }) + sizeMb = Math.round((stats.size / (1024 * 1024)) * 100) / 100 + } catch { + // Collection may not exist yet or collStats may not be available + sizeMb = 0 + } + + collectionStats = { count, sizeMb } + + // Check indexes + indexCheck = await verifyCwmpExchangesIndexes(db) + } catch { + mongoConnected = false + } + + // Get active SSE connection count + const exchangeStore = ExchangeStoreService.getInstance() + const activeConnections = exchangeStore.activeConnectionCount + + // Determine overall status + let status: 'healthy' | 'degraded' | 'unhealthy' + if (!mongoConnected) { + status = 'unhealthy' + } else if (latencyMs >= 100 || (indexCheck && !indexCheck.allPresent)) { + status = 'degraded' + } else { + status = 'healthy' + } + + const statusCode = status === 'unhealthy' ? 503 : 200 + + return response.status(statusCode).json({ + status, + mongodb: { + connected: mongoConnected, + latency_ms: latencyMs >= 0 ? latencyMs : null, + collections: collectionStats + ? { + cwmp_exchanges: { + count: collectionStats.count, + size_mb: collectionStats.sizeMb, + }, + } + : null, + indexes: indexCheck + ? { + allPresent: indexCheck.allPresent, + details: indexCheck.indexes, + } + : null, + }, + sse: { + active_connections: activeConnections, + }, + timestamp, + }) + } } diff --git a/app/middleware/cwmp_logger_middleware.ts b/app/middleware/cwmp_logger_middleware.ts index ae8399f..60500b6 100644 --- a/app/middleware/cwmp_logger_middleware.ts +++ b/app/middleware/cwmp_logger_middleware.ts @@ -3,6 +3,7 @@ import type { NextFn } from '@adonisjs/core/types/http' // @ts-ignore - xml-formatter types conflict with ESM interop import formatXml from 'xml-formatter' import { Tr069TraceService } from '#services/tr069_trace_service' +import { ExchangeStoreService } from '#services/exchange_store_service' import logger from '@adonisjs/core/services/logger' /** @@ -10,7 +11,11 @@ import logger from '@adonisjs/core/services/logger' * * Buffers SOAP request/response. After next(): * - Always logs a brief summary (INFO) to the app logger. - * - For traced devices, writes full SOAP payloads to logs/tr069/{deviceId}.log. + * - For traced devices: + * 1. Persists raw XML to ExchangeStoreService (new structured MongoDB storage) + * 2. Writes pretty-printed XML to file-based logs (legacy, kept for Phase 1 dual-write safety) + * + * The ExchangeStoreService calls are fire-and-forget: they never block CWMP processing. */ export default class CwmpLoggerMiddleware { private prettyXml(xml: string): string { @@ -52,11 +57,39 @@ export default class CwmpLoggerMiddleware { const isTraced = deviceId ? traceService.isTraced(deviceId) : false if (isTraced && deviceId) { + // New system: persist raw XML to MongoDB (fire-and-forget, never blocks CWMP) + // Ordering is guaranteed by the per-device queue in ExchangeStoreService + const exchangeStore = ExchangeStoreService.getInstance() + + // Persist CPE→ACS (including empty POST) + exchangeStore.persistExchange({ + deviceId, + direction: 'CPE→ACS', + sessionId, + rawXml: rawBody || '', + }).catch(() => {}) + + // Persist ACS→CPE response (will queue after the CPE→ACS above) + if (capturedResponseXml) { + exchangeStore.persistExchange({ + deviceId, + direction: 'ACS→CPE', + sessionId, + rawXml: capturedResponseXml, + }).catch(() => {}) + } + + // Old system: file-based writes (kept for Phase 1 dual-write rollback safety) if (rawBody && rawBody.trim() !== '') { traceService.writeExchange(deviceId, 'CPE→ACS', sessionId, this.prettyXml(rawBody)) } if (capturedResponseXml) { - traceService.writeExchange(deviceId, 'ACS→CPE', sessionId, this.prettyXml(capturedResponseXml)) + traceService.writeExchange( + deviceId, + 'ACS→CPE', + sessionId, + this.prettyXml(capturedResponseXml) + ) } } else if (rawBody && rawBody.trim() !== '') { // Brief app-level log only (no SOAP body) diff --git a/app/models/exchange.ts b/app/models/exchange.ts new file mode 100644 index 0000000..4662432 --- /dev/null +++ b/app/models/exchange.ts @@ -0,0 +1,202 @@ +import { BaseModel } from '#database/base_model' +import { getMongoDb } from '#providers/mongodb_provider' + +export interface CwmpExchange { + deviceId: string + direction: 'CPE→ACS' | 'ACS→CPE' + timestamp: string + sessionId: string + cwmpMethod: string + rawXml: string + sequenceNumber: number + createdAt?: Date +} + +export interface ExchangeMetadata { + recordedFrom: string | null + recordedUntil: string | null + count: number +} + +export default class ExchangeModel extends BaseModel { + static collectionName = 'cwmp_exchanges' + + /** + * Find exchanges for a device, ordered by timestamp ascending. + * Supports cursor-based pagination with `after` (newer) and `before` (older) parameters. + * When `before` is used, returns the most recent `limit` messages before that timestamp (ascending order). + * When `latest` is true, returns the most recent `limit` messages (ascending order). + */ + static async findByDevice( + deviceId: string, + options?: { limit?: number; after?: string; before?: string; latest?: boolean } + ): Promise { + const collection = this.getCollection() + const filter: any = { deviceId } + + if (options?.after) { + filter.timestamp = { $gt: options.after } + } else if (options?.before) { + filter.timestamp = { $lt: options.before } + } + + const limit = options?.limit ?? 200 + + // When using `latest` or `before`, we want the most recent messages. + // Query in descending order then reverse to return in ascending order. + if (options?.latest || options?.before) { + const results = await collection + .find(filter) + .sort({ timestamp: -1 }) + .limit(limit) + .toArray() as unknown as CwmpExchange[] + return results.reverse() + } + + return collection + .find(filter) + .sort({ timestamp: 1 }) + .limit(limit) + .toArray() as unknown as Promise + } + + /** + * Find exchanges with sequence number greater than given value. + * Used for SSE reconnection via Last-Event-ID. + */ + static async findAfterSequence(deviceId: string, seqNumber: number): Promise { + const collection = this.getCollection() + return collection + .find({ deviceId, sequenceNumber: { $gt: seqNumber } }) + .sort({ sequenceNumber: 1 }) + .toArray() as unknown as Promise + } + + /** + * Delete all exchanges for a device. + * Returns the number of deleted documents. + */ + static async deleteByDevice(deviceId: string): Promise { + const collection = this.getCollection() + const result = await collection.deleteMany({ deviceId }) + return result.deletedCount + } + + /** + * Get the maximum sequence number for a device. + * Returns 0 if no exchanges exist. + */ + static async getMaxSequenceNumber(deviceId: string): Promise { + const collection = this.getCollection() + const result = await collection + .find({ deviceId }) + .sort({ sequenceNumber: -1 }) + .limit(1) + .toArray() + + return result.length > 0 ? result[0].sequenceNumber : 0 + } + + /** + * Count exchanges for a device. + */ + static async countByDevice(deviceId: string): Promise { + const collection = this.getCollection() + return collection.countDocuments({ deviceId }) + } + + /** + * Delete oldest exchanges exceeding the cap for a device. + * Keeps the most recent `maxCount` exchanges (by sequenceNumber). + */ + static async deleteOldestBeyondCap(deviceId: string, maxCount: number): Promise { + const collection = this.getCollection() + const count = await collection.countDocuments({ deviceId }) + + if (count <= maxCount) return + + const toDelete = count - maxCount + const oldest = await collection + .find({ deviceId }) + .sort({ sequenceNumber: 1 }) + .limit(toDelete) + .project({ sequenceNumber: 1 }) + .toArray() + + if (oldest.length === 0) return + + const maxSeqToDelete = oldest[oldest.length - 1].sequenceNumber + await collection.deleteMany({ + deviceId, + sequenceNumber: { $lte: maxSeqToDelete }, + }) + } + + /** + * Check if any exchange exists for a device (ever traced). + */ + static async hasExchanges(deviceId: string): Promise { + const collection = this.getCollection() + const doc = await collection.findOne({ deviceId }, { projection: { _id: 1 } }) + return doc !== null + } + + /** + * Get metadata (oldest and newest timestamps, count). + */ + static async getMetadata(deviceId: string): Promise { + const collection = this.getCollection() + + const count = await collection.countDocuments({ deviceId }) + if (count === 0) { + return { recordedFrom: null, recordedUntil: null, count: 0 } + } + + const [oldest] = await collection + .find({ deviceId }) + .sort({ timestamp: 1 }) + .limit(1) + .project({ timestamp: 1 }) + .toArray() + + const [newest] = await collection + .find({ deviceId }) + .sort({ timestamp: -1 }) + .limit(1) + .project({ timestamp: 1 }) + .toArray() + + return { + recordedFrom: oldest?.timestamp ?? null, + recordedUntil: newest?.timestamp ?? null, + count, + } + } + + /** + * Ensure required indexes exist on the cwmp_exchanges collection. + * Creates indexes for: + * - (deviceId, timestamp): efficient retrieval by device ordered by time + * - (deviceId, sequenceNumber) unique: atomic sequence numbering, Last-Event-ID queries + * - (createdAt) TTL 24h: auto-remove old exchanges + */ + static async ensureIndexes(): Promise { + const db = getMongoDb() + const collection = db.collection('cwmp_exchanges') + + await collection.createIndex( + { deviceId: 1, timestamp: 1 }, + { name: 'idx_deviceId_timestamp', background: true } + ) + + await collection.createIndex( + { deviceId: 1, sequenceNumber: 1 }, + { unique: true, name: 'idx_deviceId_sequenceNumber', background: true } + ) + + await collection.createIndex( + { createdAt: 1 }, + { expireAfterSeconds: 86400, name: 'idx_createdAt_ttl', background: true } + ) + } +} diff --git a/app/services/configuration_engine.ts b/app/services/configuration_engine.ts index 9aca8ed..50366c6 100644 --- a/app/services/configuration_engine.ts +++ b/app/services/configuration_engine.ts @@ -368,7 +368,7 @@ export default class ConfigurationEngine { // Compare values (loose: both as strings to handle type mismatches) // If the desired type is numeric, compare numerically - const desiredType = desired.type || '' + const desiredType: string = desired.type || '' const isNumeric = desiredType === 'unsignedInt' || desiredType === 'int' || desiredType === 'xsd:unsignedInt' || desiredType === 'xsd:int' const isBool = desiredType === 'boolean' || desiredType === 'xsd:boolean' diff --git a/app/services/exchange_index_checker.ts b/app/services/exchange_index_checker.ts new file mode 100644 index 0000000..83f8731 --- /dev/null +++ b/app/services/exchange_index_checker.ts @@ -0,0 +1,87 @@ +import { Db } from 'mongodb' +import { getMongoDb } from '#providers/mongodb_provider' + +/** + * Expected indexes on the `cwmp_exchanges` collection. + * Each entry describes the index name and key pattern for verification. + */ +const EXPECTED_INDEXES = [ + { + name: 'idx_deviceId_timestamp', + key: { deviceId: 1, timestamp: 1 }, + description: 'Compound index (deviceId, timestamp)', + }, + { + name: 'idx_deviceId_sequenceNumber', + key: { deviceId: 1, sequenceNumber: 1 }, + description: 'Unique index (deviceId, sequenceNumber)', + }, + { + name: 'idx_createdAt_ttl', + key: { createdAt: 1 }, + description: 'TTL index (createdAt, 86400s)', + }, +] + +export interface IndexCheckResult { + name: string + description: string + exists: boolean +} + +export interface IndexVerificationResult { + collection: string + allPresent: boolean + indexes: IndexCheckResult[] +} + +/** + * Verifies that all expected indexes exist on the cwmp_exchanges collection. + * Returns a structured result with per-index status. + */ +export async function verifyCwmpExchangesIndexes(db?: Db): Promise { + const database = db ?? getMongoDb() + const collection = database.collection('cwmp_exchanges') + + let existingIndexes: { name?: string }[] + try { + existingIndexes = await collection.indexes() + } catch { + // Collection may not exist yet + existingIndexes = [] + } + + const existingNames = new Set(existingIndexes.map((idx) => idx.name)) + + const indexes: IndexCheckResult[] = EXPECTED_INDEXES.map((expected) => ({ + name: expected.name, + description: expected.description, + exists: existingNames.has(expected.name), + })) + + const allPresent = indexes.every((idx) => idx.exists) + + return { + collection: 'cwmp_exchanges', + allPresent, + indexes, + } +} + +/** + * Runs index verification at startup and logs warnings for missing indexes. + * Intended to be called after MongoDB provider has booted. + */ +export async function verifyIndexesAtStartup(db: Db, log: { warn: (msg: string) => void; info: (msg: string) => void }): Promise { + const result = await verifyCwmpExchangesIndexes(db) + + if (result.allPresent) { + log.info('[ExchangeIndexCheck] All cwmp_exchanges indexes verified OK') + return + } + + const missing = result.indexes.filter((idx) => !idx.exists) + for (const idx of missing) { + log.warn(`[ExchangeIndexCheck] Missing index on cwmp_exchanges: ${idx.name} (${idx.description})`) + } +} diff --git a/app/services/exchange_metrics_service.ts b/app/services/exchange_metrics_service.ts new file mode 100644 index 0000000..bf85b61 --- /dev/null +++ b/app/services/exchange_metrics_service.ts @@ -0,0 +1,222 @@ +import logger from '@adonisjs/core/services/logger' + +/** + * Tracks percentile-based timing metrics for the TR-069 Exchange Viewer subsystem. + * + * Collects: + * - MongoDB write latency (p50, p95, p99) + * - SSE push latency (persist → client receive) + * - API response times by endpoint + * + * Exposes metrics via structured logs (no Prometheus setup required). + * Periodically logs aggregated stats every `flushIntervalMs` milliseconds. + */ + +export interface MetricsSummary { + count: number + p50: number + p95: number + p99: number + min: number + max: number + avg: number +} + +export interface AggregatedMetrics { + mongoWriteLatency: MetricsSummary | null + ssePushLatency: MetricsSummary | null + apiResponseTimes: Record + collectedAt: string + windowMs: number +} + +const DEFAULT_FLUSH_INTERVAL_MS = 60_000 // 1 minute +const DEFAULT_MAX_SAMPLES = 10_000 // cap memory usage + +export class ExchangeMetricsService { + private static instance: ExchangeMetricsService + + private mongoWriteSamples: number[] = [] + private ssePushSamples: number[] = [] + private apiResponseSamples: Map = new Map() + + private flushIntervalMs: number + private maxSamples: number + private flushTimer: ReturnType | null = null + private lastFlushAt: number = Date.now() + + private constructor( + flushIntervalMs: number = DEFAULT_FLUSH_INTERVAL_MS, + maxSamples: number = DEFAULT_MAX_SAMPLES + ) { + this.flushIntervalMs = flushIntervalMs + this.maxSamples = maxSamples + } + + static getInstance( + flushIntervalMs?: number, + maxSamples?: number + ): ExchangeMetricsService { + if (!ExchangeMetricsService.instance) { + ExchangeMetricsService.instance = new ExchangeMetricsService(flushIntervalMs, maxSamples) + } + return ExchangeMetricsService.instance + } + + /** + * Reset singleton (for testing only). + */ + static resetInstance(): void { + if (ExchangeMetricsService.instance) { + ExchangeMetricsService.instance.stop() + } + ExchangeMetricsService.instance = undefined as any + } + + /** + * Start the periodic flush timer. + * Safe to call multiple times (idempotent). + */ + start(): void { + if (this.flushTimer) return + this.lastFlushAt = Date.now() + this.flushTimer = setInterval(() => this.flush(), this.flushIntervalMs) + // Prevent the timer from keeping the process alive + if (this.flushTimer.unref) { + this.flushTimer.unref() + } + } + + /** + * Stop the periodic flush timer and perform a final flush. + */ + stop(): void { + if (this.flushTimer) { + clearInterval(this.flushTimer) + this.flushTimer = null + } + } + + /** + * Record a MongoDB write latency sample (in milliseconds). + */ + recordMongoWriteLatency(durationMs: number): void { + this.addSample(this.mongoWriteSamples, durationMs) + } + + /** + * Record an SSE push latency sample (in milliseconds). + * This is the time from persist completion to the client receiving the event. + */ + recordSsePushLatency(durationMs: number): void { + this.addSample(this.ssePushSamples, durationMs) + } + + /** + * Record an API response time sample (in milliseconds) for a specific endpoint. + * @param endpoint - e.g. "GET /exchanges", "DELETE /exchanges", "GET /exchanges/stream" + */ + recordApiResponseTime(endpoint: string, durationMs: number): void { + if (!this.apiResponseSamples.has(endpoint)) { + this.apiResponseSamples.set(endpoint, []) + } + this.addSample(this.apiResponseSamples.get(endpoint)!, durationMs) + } + + /** + * Compute aggregated metrics without clearing samples. + * Useful for health checks or on-demand inspection. + */ + getAggregatedMetrics(): AggregatedMetrics { + const now = Date.now() + const windowMs = now - this.lastFlushAt + + const apiResponseTimes: Record = {} + for (const [endpoint, samples] of this.apiResponseSamples) { + const summary = this.computeSummary(samples) + if (summary) { + apiResponseTimes[endpoint] = summary + } + } + + return { + mongoWriteLatency: this.computeSummary(this.mongoWriteSamples), + ssePushLatency: this.computeSummary(this.ssePushSamples), + apiResponseTimes, + collectedAt: new Date(now).toISOString(), + windowMs, + } + } + + /** + * Flush aggregated metrics to structured logs and reset samples. + */ + flush(): AggregatedMetrics { + const metrics = this.getAggregatedMetrics() + + // Only log if there are any samples + const hasData = + metrics.mongoWriteLatency !== null || + metrics.ssePushLatency !== null || + Object.keys(metrics.apiResponseTimes).length > 0 + + if (hasData) { + logger.info( + { metrics }, + '[ExchangeMetrics] Aggregated performance metrics' + ) + } + + // Reset samples + this.mongoWriteSamples = [] + this.ssePushSamples = [] + this.apiResponseSamples.clear() + this.lastFlushAt = Date.now() + + return metrics + } + + /** + * Compute percentile summary for a set of samples. + * Returns null if no samples are available. + */ + computeSummary(samples: number[]): MetricsSummary | null { + if (samples.length === 0) return null + + const sorted = [...samples].sort((a, b) => a - b) + const count = sorted.length + const sum = sorted.reduce((acc, v) => acc + v, 0) + + return { + count, + p50: this.percentile(sorted, 50), + p95: this.percentile(sorted, 95), + p99: this.percentile(sorted, 99), + min: sorted[0], + max: sorted[count - 1], + avg: Math.round((sum / count) * 100) / 100, + } + } + + /** + * Calculate percentile value from a sorted array using nearest-rank method. + */ + private percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0 + if (sorted.length === 1) return sorted[0] + + const index = Math.ceil((p / 100) * sorted.length) - 1 + return sorted[Math.max(0, Math.min(index, sorted.length - 1))] + } + + /** + * Add a sample to a samples array, respecting the max capacity. + * When at capacity, the oldest sample is dropped (FIFO). + */ + private addSample(samples: number[], value: number): void { + if (samples.length >= this.maxSamples) { + samples.shift() + } + samples.push(value) + } +} diff --git a/app/services/exchange_store_service.ts b/app/services/exchange_store_service.ts new file mode 100644 index 0000000..505e5b6 --- /dev/null +++ b/app/services/exchange_store_service.ts @@ -0,0 +1,370 @@ +import { EventEmitter } from 'node:events' +import { getMongoDb } from '#providers/mongodb_provider' +import ExchangeModel from '#models/exchange' +import type { CwmpExchange } from '#models/exchange' +import exchangeStoreConfig from '#config/exchange_store' +import logger from '@adonisjs/core/services/logger' +import { ExchangeMetricsService } from '#services/exchange_metrics_service' + +export interface PersistOptions { + deviceId: string + direction: 'CPE→ACS' | 'ACS→CPE' + sessionId: string + rawXml: string +} + +const MAX_RAW_XML_BYTES = 2 * 1024 * 1024 // 2MB + +/** + * Critical CWMP methods that warrant retry on persistence failure. + * These are RPC calls, responses, and faults that carry important protocol data. + */ +export const CRITICAL_METHODS = new Set([ + 'Inform', + 'GetParameterValues', + 'GetParameterValuesResponse', + 'SetParameterValues', + 'SetParameterValuesResponse', + 'AddObject', + 'DeleteObject', + 'Download', + 'Upload', + 'Reboot', + 'FactoryReset', + 'Fault', +]) + +/** + * Non-critical CWMP methods that are discarded immediately on failure. + */ +export const NON_CRITICAL_METHODS = new Set(['Empty', 'Unknown']) + +export class ExchangeStoreService { + private static instance: ExchangeStoreService + private emitter: EventEmitter + private _activeConnectionCount: number = 0 + /** Per-device queue to serialize persist operations and guarantee ordering */ + private deviceQueues: Map> = new Map() + + private constructor() { + this.emitter = new EventEmitter() + this.emitter.setMaxListeners(100) + } + + static getInstance(): ExchangeStoreService { + if (!ExchangeStoreService.instance) { + ExchangeStoreService.instance = new ExchangeStoreService() + } + return ExchangeStoreService.instance + } + + /** + * Extract CWMP method name from SOAP XML body. + * Reads the first child element name under the `cwmp:` namespace prefix. + * Returns "Empty" if the XML is empty/whitespace-only. + * Returns "Unknown" if no `cwmp:` namespace element is found. + */ + extractCwmpMethod(xml: string): string { + if (!xml || xml.trim() === '') { + return 'Empty' + } + + // Try to find the cwmp method inside soap-env:Body first (skips cwmp:ID in Header) + const bodyMatch = xml.match(/]*>[\s\S]*?]/) + let method: string | null = null + if (bodyMatch) { + method = bodyMatch[1] + } else { + // Fallback: match any cwmp: element that is NOT "ID" + const allMatches = xml.matchAll(/]/g) + for (const m of allMatches) { + if (m[1] !== 'ID') { + method = m[1] + break + } + } + } + + if (!method) { + return 'Unknown' + } + + // For Inform messages, extract event codes to enrich the display + if (method === 'Inform') { + const events = this.extractInformEvents(xml) + if (events.length > 0) { + return `Inform (${events.join(', ')})` + } + } + + return method + } + + /** + * Extract event codes from an Inform SOAP message. + * Looks for elements and returns short labels like "BOOT", "PERIODIC", etc. + */ + private extractInformEvents(xml: string): string[] { + const eventRegex = /]*>([^<]+)<\/EventCode>/g + const events: string[] = [] + let match: RegExpExecArray | null + + while ((match = eventRegex.exec(xml)) !== null) { + const raw = match[1].trim() + // Extract just the label part (e.g., "0 BOOTSTRAP" → "BOOTSTRAP", "2 PERIODIC" → "PERIODIC") + const label = raw.replace(/^\d+\s+/, '') + if (label) { + events.push(label) + } + } + + return events + } + + /** + * Persist an exchange with per-device queuing to guarantee strict ordering. + * Each device's exchanges are serialized — no concurrent persists for the same device. + * This method is fire-and-forget: it never throws and never blocks CWMP processing. + * Checks the FEATURE_EXCHANGE_PERSISTENCE flag before persisting. + */ + async persistExchange(options: PersistOptions): Promise { + if (!exchangeStoreConfig.featureFlags.persistence) { + return null + } + + // Queue the operation to serialize per-device + const prev = this.deviceQueues.get(options.deviceId) || Promise.resolve() + const current = prev.then(() => this.doPersistExchange(options)).catch(() => null) + this.deviceQueues.set(options.deviceId, current) + return current + } + + /** + * Internal persist implementation (called within the per-device queue). + */ + private async doPersistExchange(options: PersistOptions): Promise { + + try { + let rawXml = options.rawXml || '' + + // Truncate rawXml at 2MB with a log warning + if (Buffer.byteLength(rawXml, 'utf-8') > MAX_RAW_XML_BYTES) { + logger.warn( + { deviceId: options.deviceId, originalSize: Buffer.byteLength(rawXml, 'utf-8') }, + '[ExchangeStore] rawXml exceeds 2MB, truncating' + ) + rawXml = rawXml.slice(0, MAX_RAW_XML_BYTES) + } + + const cwmpMethod = this.extractCwmpMethod(rawXml) + const sequenceNumber = await this.getNextSequenceNumber(options.deviceId) + const timestamp = new Date().toISOString() + + const exchange: CwmpExchange = { + deviceId: options.deviceId, + direction: options.direction, + timestamp, + sessionId: options.sessionId, + cwmpMethod, + rawXml, + sequenceNumber, + createdAt: new Date(), + } + + const isCritical = this.isCriticalMethod(cwmpMethod) + + // Track MongoDB write latency + const writeStart = performance.now() + await this.persistWithRetry(exchange, isCritical) + const writeLatencyMs = performance.now() - writeStart + ExchangeMetricsService.getInstance().recordMongoWriteLatency(writeLatencyMs) + + // Structured log: persistence success with all required fields + logger.info( + { + deviceId: options.deviceId, + cwmpMethod, + direction: options.direction, + sequenceNumber, + durationMs: Math.round(writeLatencyMs * 100) / 100, + operation: 'persist_exchange', + status: 'success', + isCritical, + sessionId: options.sessionId, + }, + '[ExchangeStore] Exchange persisted successfully' + ) + + // Enforce message cap after successful persist (non-blocking) + await this.enforceMessageCap(options.deviceId) + + // Emit event for SSE subscribers (attach emit timestamp for SSE push latency tracking) + ;(exchange as any)._emitTimestamp = performance.now() + this.emitter.emit(`exchange:${options.deviceId}`, exchange) + + return exchange + } catch (error) { + logger.error( + { + deviceId: options.deviceId, + cwmpMethod: this.extractCwmpMethod(options.rawXml || ''), + direction: options.direction, + operation: 'persist_exchange', + status: 'failure', + error: (error as Error).message, + sessionId: options.sessionId, + }, + '[ExchangeStore] Failed to persist exchange' + ) + return null + } + } + + /** + * Determine if a CWMP method is critical (warrants retry on failure). + * Critical methods include RPC calls, responses, and faults. + * Non-critical methods (Empty, Unknown) are discarded immediately on failure. + */ + isCriticalMethod(cwmpMethod: string): boolean { + return CRITICAL_METHODS.has(cwmpMethod) + } + + /** + * Retry logic for critical message persistence. + * + * - Critical messages: up to maxAttempts retries with exponential backoff + * (baseDelayMs * 2^attempt: 1s, 2s, 4s by default) + * - Non-critical messages: discarded immediately on first failure + * - NEVER throws — logs error and discards after all retries exhausted + * - Ensures CWMP session is never interrupted regardless of persistence outcome + */ + async persistWithRetry(doc: CwmpExchange, isCritical: boolean): Promise { + const { maxAttempts, baseDelayMs } = exchangeStoreConfig.retry + + // First attempt (always) + try { + const collection = ExchangeModel.getCollection() + await collection.insertOne(doc as any) + return + } catch (error) { + if (!isCritical) { + // Non-critical: log and discard immediately + logger.warn( + { deviceId: doc.deviceId, cwmpMethod: doc.cwmpMethod, error: String(error) }, + '[ExchangeStore] Non-critical message persistence failed — discarding' + ) + return + } + + logger.warn( + { deviceId: doc.deviceId, cwmpMethod: doc.cwmpMethod, error: String(error) }, + '[ExchangeStore] Critical message persistence failed — starting retries' + ) + } + + // Retry loop for critical messages + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const delayMs = baseDelayMs * Math.pow(2, attempt) + + await this.sleep(delayMs) + + try { + const collection = ExchangeModel.getCollection() + await collection.insertOne(doc as any) + logger.info( + { deviceId: doc.deviceId, cwmpMethod: doc.cwmpMethod, attempt: attempt + 1 }, + '[ExchangeStore] Critical message persisted after retry' + ) + return + } catch (error) { + logger.warn( + { + deviceId: doc.deviceId, + cwmpMethod: doc.cwmpMethod, + attempt: attempt + 1, + maxAttempts, + error: String(error), + }, + '[ExchangeStore] Retry failed' + ) + } + } + + // All retries exhausted — log error and discard (NEVER throw) + logger.error( + { deviceId: doc.deviceId, cwmpMethod: doc.cwmpMethod, maxAttempts }, + '[ExchangeStore] All retries exhausted for critical message — discarding' + ) + } + + /** + * Sleep utility for exponential backoff. + */ + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) + } + + /** + * Get next sequence number for a device using atomic increment. + * Uses the `cwmp_exchange_counters` collection with findOneAndUpdate + upsert. + */ + async getNextSequenceNumber(deviceId: string): Promise { + const db = getMongoDb() + const counters = db.collection('cwmp_exchange_counters') + const result = await counters.findOneAndUpdate( + { _id: deviceId as any }, + { $inc: { seq: 1 } }, + { upsert: true, returnDocument: 'after' } + ) + return result!.seq as number + } + + /** + * Subscribe to exchange events for a specific device. + * The listener will be called with each new CwmpExchange as it's persisted. + */ + subscribe(deviceId: string, listener: (exchange: CwmpExchange) => void): void { + this.emitter.on(`exchange:${deviceId}`, listener) + this._activeConnectionCount++ + } + + /** + * Unsubscribe from exchange events for a specific device. + */ + unsubscribe(deviceId: string, listener: (exchange: CwmpExchange) => void): void { + this.emitter.off(`exchange:${deviceId}`, listener) + this._activeConnectionCount = Math.max(0, this._activeConnectionCount - 1) + } + + /** + * Get the current count of active SSE connections (subscriptions). + */ + get activeConnectionCount(): number { + return this._activeConnectionCount + } + + /** + * Enforce max messages cap per device. + * Deletes oldest messages when count exceeds maxMessagesPerDevice. + * Errors are logged but never thrown (non-blocking). + */ + async enforceMessageCap(deviceId: string): Promise { + try { + const maxCount = exchangeStoreConfig.maxMessagesPerDevice + const count = await ExchangeModel.countByDevice(deviceId) + + if (count > maxCount) { + await ExchangeModel.deleteOldestBeyondCap(deviceId, maxCount) + logger.info( + { deviceId, count, maxCount, deleted: count - maxCount }, + '[ExchangeStore] Message cap enforced, oldest messages deleted' + ) + } + } catch (error) { + logger.error( + { deviceId, error }, + '[ExchangeStore] Error enforcing message cap (non-blocking)' + ) + } + } +} diff --git a/config/exchange_store.ts b/config/exchange_store.ts new file mode 100644 index 0000000..9636bfd --- /dev/null +++ b/config/exchange_store.ts @@ -0,0 +1,98 @@ +import env from '#start/env' + +const exchangeStoreConfig = { + /** + * Maximum number of exchanges stored per device. + * Oldest messages are deleted when this cap is exceeded. + */ + maxMessagesPerDevice: env.get('EXCHANGE_STORE_MAX_MESSAGES', 5000), + + /** + * TTL (time-to-live) in seconds for exchanges. + * MongoDB TTL index auto-removes documents older than this value. + * Default: 86400 seconds (24 hours). + */ + ttlSeconds: env.get('EXCHANGE_STORE_TTL_SECONDS', 86400), + + /** + * SSE (Server-Sent Events) configuration. + */ + sse: { + /** + * Inactivity timeout in milliseconds. + * Connection is closed if no new exchange is pushed within this period. + * Default: 300000ms (5 minutes). + */ + inactivityTimeoutMs: env.get('EXCHANGE_STORE_SSE_INACTIVITY_TIMEOUT_MS', 300_000), + + /** + * Absolute timeout in milliseconds. + * Connection is closed after this duration regardless of activity. + * Default: 7200000ms (2 hours). + */ + absoluteTimeoutMs: env.get('EXCHANGE_STORE_SSE_ABSOLUTE_TIMEOUT_MS', 7_200_000), + + /** + * Maximum concurrent SSE connections per device. + * Additional connections beyond this limit are rejected. + */ + maxConnectionsPerDevice: env.get('EXCHANGE_STORE_SSE_MAX_CONNECTIONS_PER_DEVICE', 5), + + /** + * Retry interval in milliseconds sent to clients via SSE `retry` field. + * Clients use this value when reconnecting after a disconnect. + * Default: 3000ms (3 seconds). + */ + retryIntervalMs: env.get('EXCHANGE_STORE_SSE_RETRY_INTERVAL_MS', 3000), + }, + + /** + * Retry policy for critical message persistence. + */ + retry: { + /** + * Maximum number of retry attempts for critical messages. + */ + maxAttempts: env.get('EXCHANGE_STORE_RETRY_MAX_ATTEMPTS', 3), + + /** + * Base delay in milliseconds for exponential backoff. + * Actual delays: baseDelayMs, baseDelayMs*2, baseDelayMs*4, ... + * Default: 1000ms → retries at 1s, 2s, 4s. + */ + baseDelayMs: env.get('EXCHANGE_STORE_RETRY_BASE_DELAY_MS', 1000), + }, + + /** + * Read timeout in milliseconds for exchange retrieval queries. + * If a query exceeds this duration, partial results are returned with `isTruncated: true`. + * Default: 5000ms (5 seconds). + */ + readTimeoutMs: env.get('EXCHANGE_STORE_READ_TIMEOUT_MS', 5000), + + /** + * Feature flags for gradual rollout and rollback. + */ + featureFlags: { + /** + * Enable/disable MongoDB persistence of exchanges. + * When false, exchanges are not written to MongoDB (CWMP sessions unaffected). + */ + persistence: env.get('FEATURE_EXCHANGE_PERSISTENCE', true), + + /** + * Enable/disable SSE streaming endpoint. + * When false, the SSE endpoint returns 503 and clients fall back to REST polling. + */ + sseEnabled: env.get('FEATURE_SSE_ENABLED', true), + + /** + * Data source for the Exchange API. + * 'mongodb': read from structured MongoDB collection (new system). + * 'file': read from legacy file-based logs (old system). + */ + apiSource: env.get('FEATURE_EXCHANGE_API_SOURCE', 'mongodb') as 'mongodb' | 'file', + }, +} + +export default exchangeStoreConfig diff --git a/database/mongo_migrations/011_create_cwmp_exchanges_collection.ts b/database/mongo_migrations/011_create_cwmp_exchanges_collection.ts new file mode 100644 index 0000000..2b33769 --- /dev/null +++ b/database/mongo_migrations/011_create_cwmp_exchanges_collection.ts @@ -0,0 +1,33 @@ +import { MongoMigration } from '../mongo_migrator.js' + +export default class CreateCwmpExchangesCollection extends MongoMigration { + async up(): Promise { + const exchanges = this.db.collection('cwmp_exchanges') + + // Compound index for efficient retrieval by device, ordered by time + await exchanges.createIndex( + { deviceId: 1, timestamp: 1 }, + { name: 'idx_deviceId_timestamp' } + ) + + // Unique index for atomic sequence numbering and Last-Event-ID queries + await exchanges.createIndex( + { deviceId: 1, sequenceNumber: 1 }, + { unique: true, name: 'idx_deviceId_sequenceNumber' } + ) + + // TTL index to auto-remove exchanges older than 24h + await exchanges.createIndex( + { createdAt: 1 }, + { expireAfterSeconds: 86400, name: 'idx_createdAt_ttl' } + ) + + // Create counters collection for atomic sequence number generation + this.db.collection('cwmp_exchange_counters') + } + + async down(): Promise { + await this.db.dropCollection('cwmp_exchanges').catch(() => {}) + await this.db.dropCollection('cwmp_exchange_counters').catch(() => {}) + } +} diff --git a/database/seeders/cwmp_exchanges_seeder.ts b/database/seeders/cwmp_exchanges_seeder.ts new file mode 100644 index 0000000..5d75e69 --- /dev/null +++ b/database/seeders/cwmp_exchanges_seeder.ts @@ -0,0 +1,552 @@ +/** + * Database seeder for cwmp_exchanges collection. + * + * Generates realistic CWMP exchange data for integration/manual testing. + * Can be run standalone via: npx tsx database/seeders/cwmp_exchanges_seeder.ts + * + * Configuration via environment variables: + * SEED_DEVICE_COUNT - Number of devices to generate (default: 5) + * SEED_EXCHANGES_MIN - Minimum exchanges per device (default: 20) + * SEED_EXCHANGES_MAX - Maximum exchanges per device (default: 100) + * MONGODB_URI - MongoDB connection string + * MONGODB_DB_NAME - Database name (default: adam-acs) + */ + +import { MongoClient, type Db } from 'mongodb' + +// ─── Configuration ────────────────────────────────────────────────────────── + +interface SeederConfig { + deviceCount: number + exchangesMin: number + exchangesMax: number + mongoUri: string + dbName: string +} + +function getConfig(): SeederConfig { + return { + deviceCount: Number(process.env.SEED_DEVICE_COUNT ?? 5), + exchangesMin: Number(process.env.SEED_EXCHANGES_MIN ?? 20), + exchangesMax: Number(process.env.SEED_EXCHANGES_MAX ?? 100), + mongoUri: process.env.MONGODB_URI ?? 'mongodb://localhost:27017', + dbName: process.env.MONGODB_DB_NAME ?? 'adam-acs', + } +} + +// ─── CWMP Method Templates ────────────────────────────────────────────────── + +type Direction = 'CPE→ACS' | 'ACS→CPE' + +interface ExchangeTemplate { + cwmpMethod: string + direction: Direction + xmlGenerator: () => string +} + +const MANUFACTURERS = ['Sagemcom', 'Huawei', 'ZTE', 'Nokia', 'Technicolor'] +const PRODUCT_CLASSES = ['Livebox 4', 'BM632w', 'ZXHN H298A', 'G-240W-A', 'TG789vac'] +const OUI_LIST = ['F8B7FF', '202BC1', '001D08', '00E0FC', '74BD7B'] + +function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +function randomChoice(arr: T[]): T { + return arr[randomInt(0, arr.length - 1)] +} + +function generateDeviceId(index: number): string { + const oui = OUI_LIST[index % OUI_LIST.length] + const serial = `DEVICE-${String(index + 1).padStart(3, '0')}` + return `${oui}-${PRODUCT_CLASSES[index % PRODUCT_CLASSES.length]}-${serial}` +} + +function generateSessionId(): string { + const srcIp = `10.0.${randomInt(0, 255)}.${randomInt(1, 254)}` + const srcPort = randomInt(30000, 65000) + return `${srcIp}:${srcPort}->192.168.1.1:8080` +} + +// ─── XML Body Generators ──────────────────────────────────────────────────── + +function wrapSoapEnvelope(body: string): string { + return ` + + + ${randomInt(100000, 999999)} + + + ${body} + +` +} + +function generateInformXml(deviceIndex: number): string { + const oui = OUI_LIST[deviceIndex % OUI_LIST.length] + const manufacturer = MANUFACTURERS[deviceIndex % MANUFACTURERS.length] + const productClass = PRODUCT_CLASSES[deviceIndex % PRODUCT_CLASSES.length] + const serial = `DEVICE-${String(deviceIndex + 1).padStart(3, '0')}` + const events = randomChoice([ + ['0 BOOTSTRAP', '1 BOOT'], + ['2 PERIODIC'], + ['1 BOOT'], + ['6 CONNECTION REQUEST'], + ]) + + const eventString = events + .map( + (e, i) => + `${e}${i}` + ) + .join('\n ') + + return wrapSoapEnvelope(` + + ${manufacturer} + ${oui} + ${productClass} + ${serial} + + + ${eventString} + + 1 + ${new Date().toISOString()} + 0 + + + Device.DeviceInfo.HardwareVersion + v2.1 + + + Device.DeviceInfo.SoftwareVersion + SR40_sip-fr-4.62.0 + + + Device.ManagementServer.ConnectionRequestURL + http://10.0.0.${randomInt(1, 254)}:7547/ + + + Device.ManagementServer.ParameterKey + + + + `) +} + +function generateInformResponseXml(): string { + return wrapSoapEnvelope(` + 1 + `) +} + +function generateGetParameterValuesXml(paths: string[]): string { + const paramNames = paths.map((p) => `${p}`).join('\n ') + return wrapSoapEnvelope(` + + ${paramNames} + + `) +} + +function generateGetParameterValuesResponseXml(paths: string[]): string { + const params = paths + .map( + (p) => ` + ${p} + value_${randomInt(1, 9999)} + ` + ) + .join('\n ') + return wrapSoapEnvelope(` + + ${params} + + `) +} + +function generateSetParameterValuesXml(): string { + return wrapSoapEnvelope(` + + + Device.ManagementServer.PeriodicInformInterval + ${randomInt(60, 3600)} + + + Device.DeviceInfo.ProvisioningCode + PROV-${randomInt(1000, 9999)} + + + key-${randomInt(1, 999)} + `) +} + +function generateSetParameterValuesResponseXml(): string { + return wrapSoapEnvelope(` + 0 + `) +} + +function generateGetParameterNamesXml(): string { + const path = randomChoice([ + 'Device.', + 'Device.DeviceInfo.', + 'Device.ManagementServer.', + 'Device.WiFi.', + ]) + return wrapSoapEnvelope(` + ${path} + 1 + `) +} + +function generateGetParameterNamesResponseXml(): string { + const params = ['Device.DeviceInfo.', 'Device.ManagementServer.', 'Device.WiFi.', 'Device.IP.'] + const paramList = params + .map( + (p) => ` + ${p} + 0 + ` + ) + .join('\n ') + return wrapSoapEnvelope(` + + ${paramList} + + `) +} + +function generateDownloadXml(): string { + return wrapSoapEnvelope(` + firmware-update-${randomInt(1, 99)} + 1 Firmware Upgrade Image + http://firmware.example.com/fw-v${randomInt(1, 5)}.${randomInt(0, 9)}.bin + admin + **** + ${randomInt(1000000, 50000000)} + + 0 + + + `) +} + +function generateDownloadResponseXml(): string { + const status = randomChoice([0, 1]) // 0=completed, 1=in progress + return wrapSoapEnvelope(` + ${status} + ${new Date().toISOString()} + 0001-01-01T00:00:00Z + `) +} + +function generateRebootXml(): string { + return wrapSoapEnvelope(` + reboot-${randomInt(1, 999)} + `) +} + +function generateRebootResponseXml(): string { + return wrapSoapEnvelope(``) +} + +function generateFaultXml(): string { + const faultCode = randomChoice(['9001', '9002', '9003', '9005', '9007']) + const faultMessages: Record = { + '9001': 'Request denied', + '9002': 'Internal error', + '9003': 'Invalid arguments', + '9005': 'Invalid parameter name', + '9007': 'Invalid parameter value', + } + return wrapSoapEnvelope(` + Client + CWMP fault + + + ${faultCode} + ${faultMessages[faultCode]} + + + `) +} + +function generateTransferCompleteXml(): string { + return wrapSoapEnvelope(` + firmware-update-${randomInt(1, 99)} + + 0 + + + ${new Date().toISOString()} + ${new Date().toISOString()} + `) +} + +function generateTransferCompleteResponseXml(): string { + return wrapSoapEnvelope(``) +} + +// ─── Exchange Sequence Patterns ───────────────────────────────────────────── + +/** Realistic CWMP session exchange patterns (request/response pairs) */ +const SESSION_PATTERNS: ExchangeTemplate[][] = [ + // Inform session start + [ + { cwmpMethod: 'Inform', direction: 'CPE→ACS', xmlGenerator: () => '' }, + { cwmpMethod: 'InformResponse', direction: 'ACS→CPE', xmlGenerator: generateInformResponseXml }, + ], + // GetParameterValues + [ + { + cwmpMethod: 'GetParameterValues', + direction: 'ACS→CPE', + xmlGenerator: () => + generateGetParameterValuesXml([ + 'Device.DeviceInfo.SoftwareVersion', + 'Device.DeviceInfo.HardwareVersion', + 'Device.ManagementServer.PeriodicInformInterval', + ]), + }, + { + cwmpMethod: 'GetParameterValuesResponse', + direction: 'CPE→ACS', + xmlGenerator: () => + generateGetParameterValuesResponseXml([ + 'Device.DeviceInfo.SoftwareVersion', + 'Device.DeviceInfo.HardwareVersion', + 'Device.ManagementServer.PeriodicInformInterval', + ]), + }, + ], + + // SetParameterValues + [ + { + cwmpMethod: 'SetParameterValues', + direction: 'ACS→CPE', + xmlGenerator: generateSetParameterValuesXml, + }, + { + cwmpMethod: 'SetParameterValuesResponse', + direction: 'CPE→ACS', + xmlGenerator: generateSetParameterValuesResponseXml, + }, + ], + // GetParameterNames + [ + { + cwmpMethod: 'GetParameterNames', + direction: 'ACS→CPE', + xmlGenerator: generateGetParameterNamesXml, + }, + { + cwmpMethod: 'GetParameterNamesResponse', + direction: 'CPE→ACS', + xmlGenerator: generateGetParameterNamesResponseXml, + }, + ], + // Download (firmware) + [ + { cwmpMethod: 'Download', direction: 'ACS→CPE', xmlGenerator: generateDownloadXml }, + { + cwmpMethod: 'DownloadResponse', + direction: 'CPE→ACS', + xmlGenerator: generateDownloadResponseXml, + }, + ], + // Reboot + [ + { cwmpMethod: 'Reboot', direction: 'ACS→CPE', xmlGenerator: generateRebootXml }, + { cwmpMethod: 'RebootResponse', direction: 'CPE→ACS', xmlGenerator: generateRebootResponseXml }, + ], + // TransferComplete (CPE-initiated after download) + [ + { + cwmpMethod: 'TransferComplete', + direction: 'CPE→ACS', + xmlGenerator: generateTransferCompleteXml, + }, + { + cwmpMethod: 'TransferCompleteResponse', + direction: 'ACS→CPE', + xmlGenerator: generateTransferCompleteResponseXml, + }, + ], + // Fault response + [{ cwmpMethod: 'Fault', direction: 'CPE→ACS', xmlGenerator: generateFaultXml }], + // Empty POST (session end) + [{ cwmpMethod: 'Empty', direction: 'CPE→ACS', xmlGenerator: () => '' }], +] + +// ─── Exchange Generator ───────────────────────────────────────────────────── + +interface CwmpExchangeDoc { + deviceId: string + direction: Direction + timestamp: Date + sessionId: string + cwmpMethod: string + rawXml: string + sequenceNumber: number + createdAt: Date +} + +function generateExchangesForDevice( + deviceId: string, + deviceIndex: number, + count: number +): CwmpExchangeDoc[] { + const exchanges: CwmpExchangeDoc[] = [] + const now = Date.now() + const twentyFourHoursMs = 24 * 60 * 60 * 1000 + + // Distribute exchanges over the last 24h + let sequenceNumber = 1 + let currentTimeMs = now - twentyFourHoursMs + randomInt(0, 60000) + + while (exchanges.length < count) { + // Start a new session + const sessionId = generateSessionId() + // Each session has an Inform + some exchanges + const sessionSize = Math.min(randomInt(4, 12), count - exchanges.length) + + // Always start session with Inform + const informXml = generateInformXml(deviceIndex) + const informTimestamp = new Date(currentTimeMs) + exchanges.push({ + deviceId, + direction: 'CPE→ACS', + timestamp: informTimestamp, + sessionId, + cwmpMethod: 'Inform', + rawXml: informXml, + sequenceNumber: sequenceNumber++, + createdAt: informTimestamp, + }) + currentTimeMs += randomInt(50, 200) // 50-200ms between messages + + if (exchanges.length >= count) break + + // InformResponse + const informRespTimestamp = new Date(currentTimeMs) + exchanges.push({ + deviceId, + direction: 'ACS→CPE', + timestamp: informRespTimestamp, + sessionId, + cwmpMethod: 'InformResponse', + rawXml: generateInformResponseXml(), + sequenceNumber: sequenceNumber++, + createdAt: informRespTimestamp, + }) + currentTimeMs += randomInt(50, 200) + + // Fill rest of session with random patterns + let remaining = sessionSize - 2 + while (remaining > 0 && exchanges.length < count) { + // Pick a random pattern (skip Inform pattern at index 0) + const patternIndex = randomInt(1, SESSION_PATTERNS.length - 1) + const pattern = SESSION_PATTERNS[patternIndex] + + for (const template of pattern) { + if (exchanges.length >= count) break + remaining-- + + const ts = new Date(currentTimeMs) + exchanges.push({ + deviceId, + direction: template.direction, + timestamp: ts, + sessionId, + cwmpMethod: template.cwmpMethod, + rawXml: template.xmlGenerator(), + sequenceNumber: sequenceNumber++, + createdAt: ts, + }) + currentTimeMs += randomInt(50, 300) + } + } + + // Gap between sessions (30s to 30min) + currentTimeMs += randomInt(30_000, 1_800_000) + } + + return exchanges.slice(0, count) +} + +// ─── Seeder Runner ────────────────────────────────────────────────────────── + +export async function seed(db: Db, config?: Partial): Promise { + const cfg = { ...getConfig(), ...config } + const collection = db.collection('cwmp_exchanges') + const countersCollection = db.collection('cwmp_exchange_counters') + + console.log(`[Seeder] Seeding cwmp_exchanges with:`) + console.log(` - ${cfg.deviceCount} devices`) + console.log(` - ${cfg.exchangesMin}-${cfg.exchangesMax} exchanges per device`) + + // Clear existing data + await collection.deleteMany({}) + await countersCollection.deleteMany({}) + console.log(`[Seeder] Cleared existing cwmp_exchanges and counters`) + + let totalInserted = 0 + + for (let i = 0; i < cfg.deviceCount; i++) { + const deviceId = generateDeviceId(i) + const exchangeCount = randomInt(cfg.exchangesMin, cfg.exchangesMax) + const exchanges = generateExchangesForDevice(deviceId, i, exchangeCount) + + // Bulk insert exchanges + if (exchanges.length > 0) { + await collection.insertMany(exchanges) + + // Update counter for the device + const maxSeq = exchanges[exchanges.length - 1].sequenceNumber + await countersCollection.updateOne( + { _id: deviceId as any }, + { $set: { seq: maxSeq } }, + { upsert: true } + ) + + totalInserted += exchanges.length + console.log(`[Seeder] Device "${deviceId}": ${exchanges.length} exchanges (seq 1-${maxSeq})`) + } + } + + console.log(`[Seeder] Done. Total exchanges inserted: ${totalInserted}`) +} + +// ─── Standalone Execution ─────────────────────────────────────────────────── + +async function main(): Promise { + const config = getConfig() + + console.log(`[Seeder] Connecting to MongoDB at ${config.mongoUri}/${config.dbName}...`) + const client = new MongoClient(config.mongoUri) + await client.connect() + const db = client.db(config.dbName) + + try { + await seed(db, config) + } finally { + await client.close() + console.log(`[Seeder] MongoDB connection closed.`) + } +} + +// Run directly if executed as a script +const isMainModule = process.argv[1]?.includes('cwmp_exchanges_seeder') +if (isMainModule) { + main().catch((err) => { + console.error('[Seeder] Fatal error:', err) + process.exit(1) + }) +} diff --git a/docs/api/tr069-exchanges.yaml b/docs/api/tr069-exchanges.yaml new file mode 100644 index 0000000..19be4df --- /dev/null +++ b/docs/api/tr069-exchanges.yaml @@ -0,0 +1,475 @@ +openapi: 3.0.3 +info: + title: TR-069 Exchange Viewer API + description: | + API REST pour la récupération, la suppression et le streaming en temps réel + des échanges CWMP/TR-069 capturés lors d'une session de trace sur un device. + + ## Architecture + + Les échanges SOAP sont capturés par le `CwmpLoggerMiddleware`, persistés dans + MongoDB via `ExchangeStoreService`, et diffusés en temps réel via Server-Sent Events (SSE). + + ## Authentification + + Tous les endpoints nécessitent une session authentifiée (cookie-based via AdonisJS). + + ## Feature Flags + + | Variable d'environnement | Effet | + |--------------------------|-------| + | `FEATURE_EXCHANGE_PERSISTENCE=false` | Désactive l'écriture MongoDB | + | `FEATURE_SSE_ENABLED=false` | Désactive le SSE (endpoint renvoie 503) | + | `FEATURE_EXCHANGE_API_SOURCE=file` | Lecture depuis l'ancien système fichier | + version: 1.0.0 + contact: + name: adam-acs Team + +servers: + - url: http://localhost:8080/api/v1 + description: Local development + +paths: + /tr069-trace/{deviceId}/exchanges: + get: + operationId: getExchanges + summary: Récupérer les échanges CWMP d'un device + description: | + Retourne les échanges CWMP structurés pour un device tracé, ordonnés par + timestamp ascending. Inclut des métadonnées sur le statut de la trace et + la fenêtre d'enregistrement. + + **Pagination cursor-based** : utiliser `after` pour charger les messages plus + récents, `before` pour charger les messages plus anciens, ou `latest=true` + pour obtenir les N messages les plus récents (chargement initial). + tags: + - Exchanges + parameters: + - $ref: '#/components/parameters/deviceId' + - name: limit + in: query + required: false + description: | + Nombre maximum de messages retournés. + - Défaut : 200 + - Minimum : 1 + - Maximum : 1000 + - Retourne HTTP 400 si non-numérique ou < 1 + schema: + type: integer + minimum: 1 + maximum: 1000 + default: 200 + example: 50 + - name: after + in: query + required: false + description: | + Timestamp ISO 8601 pour la pagination cursor-based (messages plus récents). + Seuls les messages avec un timestamp strictement supérieur à cette valeur + sont retournés. Retourne HTTP 400 si le format est invalide. + schema: + type: string + format: date-time + example: '2025-01-15T10:30:00.000Z' + - name: before + in: query + required: false + description: | + Timestamp ISO 8601 pour la pagination inverse (messages plus anciens). + Seuls les messages avec un timestamp strictement inférieur à cette valeur + sont retournés. Retourne HTTP 400 si le format est invalide. + schema: + type: string + format: date-time + example: '2025-01-15T10:35:00.000Z' + - name: latest + in: query + required: false + description: | + Si `true`, retourne les N messages les plus récents (pour le chargement + initial). Combiné avec `limit` pour contrôler le nombre de messages. + schema: + type: string + enum: + - 'true' + - 'false' + example: 'true' + responses: + '200': + description: Liste des échanges avec métadonnées + content: + application/json: + schema: + $ref: '#/components/schemas/ExchangeListResponse' + examples: + activeTrace: + summary: Device actuellement tracé avec des échanges + value: + exchanges: + - sequenceNumber: 1 + timestamp: '2025-01-15T10:30:00.123Z' + direction: 'CPE→ACS' + cwmpMethod: 'Inform' + sessionId: '10.0.0.1:54321->192.168.1.1:8080' + rawXml: '...' + - sequenceNumber: 2 + timestamp: '2025-01-15T10:30:00.456Z' + direction: 'ACS→CPE' + cwmpMethod: 'InformResponse' + sessionId: '10.0.0.1:54321->192.168.1.1:8080' + rawXml: '1' + metadata: + traceStatus: 'ACTIVE' + traceStoppedAt: null + recordedFrom: '2025-01-15T10:30:00.123Z' + recordedUntil: '2025-01-15T10:35:42.567Z' + total: 42 + stoppedTrace: + summary: Trace arrêtée avec historique conservé + value: + exchanges: + - sequenceNumber: 1 + timestamp: '2025-01-15T10:30:00.123Z' + direction: 'CPE→ACS' + cwmpMethod: 'Inform' + sessionId: '10.0.0.1:54321->192.168.1.1:8080' + rawXml: '...' + metadata: + traceStatus: 'STOPPED' + traceStoppedAt: '2025-01-15T10:40:00.000Z' + recordedFrom: '2025-01-15T10:30:00.123Z' + recordedUntil: '2025-01-15T10:35:42.567Z' + total: 15 + emptyTrace: + summary: Device tracé mais aucun échange encore enregistré + value: + exchanges: [] + metadata: + traceStatus: 'ACTIVE' + traceStoppedAt: null + recordedFrom: null + recordedUntil: null + total: 0 + truncatedResult: + summary: Résultat partiel (timeout de lecture dépassé) + value: + exchanges: [] + metadata: + traceStatus: 'ACTIVE' + traceStoppedAt: null + recordedFrom: '2025-01-15T10:30:00.123Z' + recordedUntil: '2025-01-15T10:35:42.567Z' + total: 5000 + isTruncated: true + '400': + description: Paramètre de requête invalide + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalidLimit: + summary: Paramètre limit invalide + value: + error: 'Invalid limit parameter. Must be an integer between 1 and 1000.' + invalidAfter: + summary: Paramètre after invalide + value: + error: 'Invalid after parameter. Must be a valid ISO 8601 timestamp.' + invalidBefore: + summary: Paramètre before invalide + value: + error: 'Invalid before parameter. Must be a valid ISO 8601 timestamp.' + '404': + description: Le device n'a jamais été tracé (aucun échange historique) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: 'No trace exists for device F8B7FF-Livebox4-LB4-TEST-001' + + delete: + operationId: deleteExchanges + summary: Supprimer tous les échanges d'un device + description: | + Supprime tous les échanges CWMP stockés pour le device spécifié. + Retourne 204 sans corps de réponse en cas de succès. + + Cette opération est irréversible. Si le device est actuellement tracé, + les nouveaux échanges continueront d'être enregistrés après la suppression. + tags: + - Exchanges + parameters: + - $ref: '#/components/parameters/deviceId' + responses: + '204': + description: Échanges supprimés avec succès (pas de corps de réponse) + + /tr069-trace/{deviceId}/exchanges/stream: + get: + operationId: streamExchanges + summary: Flux SSE temps réel des échanges CWMP + description: | + Ouvre une connexion HTTP persistante avec `Content-Type: text/event-stream` + qui pousse chaque nouvel échange CWMP dès qu'il est persisté. + + ## Protocole SSE + + La connexion utilise le protocole Server-Sent Events (SSE) standard : + + - **Content-Type** : `text/event-stream` + - **Cache-Control** : `no-cache` + - **Connection** : `keep-alive` + - **X-Accel-Buffering** : `no` (pour nginx) + + ## Format des événements + + Chaque événement SSE contient : + ``` + id: + data: + + ``` + + Exemple d'événement brut : + ``` + id: 42 + data: {"sequenceNumber":42,"timestamp":"2025-01-15T10:30:00.123Z","direction":"CPE→ACS","cwmpMethod":"Inform","sessionId":"10.0.0.1:54321->192.168.1.1:8080","rawXml":"..."} + + ``` + + ## Reconnexion (Last-Event-ID) + + Lors d'une reconnexion, le client envoie l'en-tête `Last-Event-ID` avec le + `sequenceNumber` du dernier événement reçu. Le serveur reprend alors à partir + du message suivant (sequenceNumber > Last-Event-ID), sans duplication. + + Le serveur envoie un champ `retry` au début de la connexion (défaut: 3000ms) + pour configurer l'intervalle de reconnexion automatique du client. + + ## Timeouts serveur + + Le serveur ferme la connexion dans les cas suivants : + - **Inactivité** : aucun nouvel échange poussé pendant 5 minutes + - **Durée absolue** : connexion ouverte depuis plus de 2 heures + + Dans les deux cas, le client devrait se reconnecter automatiquement + (mécanisme natif EventSource avec Last-Event-ID). + + ## Feature Flag + + Si `FEATURE_SSE_ENABLED=false`, cet endpoint retourne HTTP 503. + tags: + - Exchanges + - SSE + parameters: + - $ref: '#/components/parameters/deviceId' + - name: Last-Event-ID + in: header + required: false + description: | + Sequence number du dernier événement reçu par le client. + Utilisé pour reprendre le flux après une déconnexion sans duplication. + schema: + type: string + example: '42' + responses: + '200': + description: Flux SSE ouvert avec succès + headers: + Content-Type: + schema: + type: string + example: text/event-stream + Cache-Control: + schema: + type: string + example: no-cache + Connection: + schema: + type: string + example: keep-alive + X-Accel-Buffering: + schema: + type: string + example: 'no' + content: + text/event-stream: + schema: + type: string + description: Flux d'événements SSE (voir description pour le format) + example: | + retry: 3000 + + id: 1 + data: {"sequenceNumber":1,"timestamp":"2025-01-15T10:30:00.123Z","direction":"CPE→ACS","cwmpMethod":"Inform","sessionId":"10.0.0.1:54321->192.168.1.1:8080","rawXml":"..."} + + id: 2 + data: {"sequenceNumber":2,"timestamp":"2025-01-15T10:30:00.456Z","direction":"ACS→CPE","cwmpMethod":"InformResponse","sessionId":"10.0.0.1:54321->192.168.1.1:8080","rawXml":"..."} + + '404': + description: Le device n'est pas actuellement tracé + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: 'Device is not being traced' + '503': + description: Le streaming SSE est désactivé (feature flag) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: 'SSE streaming is currently disabled' + +components: + parameters: + deviceId: + name: deviceId + in: path + required: true + description: | + Identifiant du device (format: `OUI-ProductClass-SerialNumber` + ou hash MongoDB `_id`). + schema: + type: string + maxLength: 128 + example: 'F8B7FF-Livebox4-LB4-TEST-001' + + schemas: + CwmpExchange: + type: object + description: Un échange SOAP CWMP individuel capturé + required: + - sequenceNumber + - timestamp + - direction + - cwmpMethod + - sessionId + - rawXml + properties: + sequenceNumber: + type: integer + description: | + Numéro de séquence monotoniquement croissant par device (commence à 1). + Utilisé comme identifiant d'événement SSE pour la reconnexion. + example: 42 + timestamp: + type: string + format: date-time + description: Horodatage UTC de la capture (ISO 8601) + example: '2025-01-15T10:30:00.123Z' + direction: + type: string + enum: + - 'CPE→ACS' + - 'ACS→CPE' + description: | + Direction du message : + - `CPE→ACS` : requête envoyée par le CPE vers l'ACS + - `ACS→CPE` : réponse envoyée par l'ACS vers le CPE + example: 'CPE→ACS' + cwmpMethod: + type: string + description: | + Méthode CWMP extraite du body SOAP : + - Nom de la méthode (ex: `Inform`, `GetParameterValues`, `SetParameterValuesResponse`) + - `"Empty"` si le body XML est vide (empty POST du CPE) + - `"Unknown"` si aucun élément `cwmp:` n'est trouvé dans le body + example: 'Inform' + sessionId: + type: string + description: Identifiant de session TCP (format `IP:port->IP:port`) + example: '10.0.0.1:54321->192.168.1.1:8080' + rawXml: + type: string + description: | + Contenu XML SOAP brut du message (maximum 2 Mo). + Peut être vide pour les messages de type "Empty". + maxLength: 2097152 + example: '...' + + ExchangeMetadata: + type: object + description: Métadonnées sur la session de trace du device + required: + - traceStatus + - traceStoppedAt + - recordedFrom + - recordedUntil + - total + properties: + traceStatus: + type: string + enum: + - ACTIVE + - STOPPED + description: | + Statut actuel de la trace : + - `ACTIVE` : le device est en cours de trace + - `STOPPED` : la trace a été arrêtée (historique conservé) + example: 'ACTIVE' + traceStoppedAt: + type: string + format: date-time + nullable: true + description: Timestamp d'arrêt de la trace (null si active) + example: null + recordedFrom: + type: string + format: date-time + nullable: true + description: Timestamp du plus ancien échange enregistré + example: '2025-01-15T10:30:00.123Z' + recordedUntil: + type: string + format: date-time + nullable: true + description: Timestamp du plus récent échange enregistré + example: '2025-01-15T10:35:42.567Z' + total: + type: integer + description: Nombre total d'échanges stockés pour ce device + example: 42 + isTruncated: + type: boolean + description: | + Présent uniquement si le résultat est partiel (timeout de lecture dépassé). + Le client devrait réessayer avec des paramètres de pagination plus restreints. + example: true + + ExchangeListResponse: + type: object + description: Réponse de l'endpoint GET /exchanges + required: + - exchanges + - metadata + properties: + exchanges: + type: array + items: + $ref: '#/components/schemas/CwmpExchange' + description: Liste des échanges ordonnés par timestamp ascending + metadata: + $ref: '#/components/schemas/ExchangeMetadata' + + ErrorResponse: + type: object + description: Réponse d'erreur standard + required: + - error + properties: + error: + type: string + description: Message d'erreur décrivant le problème + example: 'Invalid limit parameter. Must be an integer between 1 and 1000.' + +tags: + - name: Exchanges + description: Gestion des échanges CWMP capturés + - name: SSE + description: Streaming temps réel via Server-Sent Events diff --git a/docs/deployment/tr069-exchange-viewer.md b/docs/deployment/tr069-exchange-viewer.md new file mode 100644 index 0000000..c45971c --- /dev/null +++ b/docs/deployment/tr069-exchange-viewer.md @@ -0,0 +1,363 @@ +# Deployment Guide: TR-069 Exchange Viewer + +## Overview + +The TR-069 Exchange Viewer stores CWMP protocol exchanges in MongoDB and streams them in real-time via SSE (Server-Sent Events) to a frontend sequence diagram. This guide covers the infrastructure requirements, configuration, feature flags, and monitoring needed for deployment. + +## MongoDB Setup + +### Requirements + +- **MongoDB version**: 4.4+ (tested with 5.0 and 6.0) +- **Replica set**: Not required. The system uses an in-process EventEmitter for real-time notifications instead of MongoDB Change Streams. +- **Authentication**: Standard MongoDB credentials (same as the existing adam-acs database) + +### Collections + +The feature creates two MongoDB collections: + +| Collection | Purpose | +|-----------|---------| +| `cwmp_exchanges` | Stores structured CWMP exchange documents | +| `cwmp_exchange_counters` | Atomic counters for per-device sequence number generation | + +### Indexes + +Three indexes are created on `cwmp_exchanges`: + +| Name | Fields | Options | Purpose | +|------|--------|---------|---------| +| `idx_deviceId_timestamp` | `{ deviceId: 1, timestamp: 1 }` | — | Efficient retrieval by device, ordered by time | +| `idx_deviceId_sequenceNumber` | `{ deviceId: 1, sequenceNumber: 1 }` | `unique: true` | Atomic sequence numbering, Last-Event-ID queries | +| `idx_createdAt_ttl` | `{ createdAt: 1 }` | `expireAfterSeconds: 86400` | Auto-remove exchanges older than 24 hours | + +### Running the Migration + +Indexes and collections are created via migration `011_create_cwmp_exchanges_collection.ts`: + +```bash +# Run all pending migrations +node ace migration:run + +# Verify migration applied +node ace migration:status +``` + +### Index Verification + +On startup, the application checks that all expected indexes exist and logs warnings if any are missing. The health check endpoint also reports index status (see [Health Check](#health-check-endpoint) below). + +To manually verify indexes in the MongoDB shell: + +```javascript +db.cwmp_exchanges.getIndexes() +// Expected: _id, idx_deviceId_timestamp, idx_deviceId_sequenceNumber, idx_createdAt_ttl +``` + +### Storage Considerations + +- **TTL**: Documents are automatically removed after 24 hours (configurable via `EXCHANGE_STORE_TTL_SECONDS`) +- **Message cap**: Maximum 5000 exchanges per device (configurable via `EXCHANGE_STORE_MAX_MESSAGES`) +- **Document size**: Each exchange document stores raw XML (max 2 MB per message) +- **Estimated storage**: For 100 traced devices with 5000 messages averaging 10 KB each → ~5 GB peak + +--- + +## Environment Variables + +### Feature Flags + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `FEATURE_EXCHANGE_PERSISTENCE` | Boolean | `true` | Enable/disable MongoDB persistence of exchanges | +| `FEATURE_SSE_ENABLED` | Boolean | `true` | Enable/disable the SSE streaming endpoint | +| `FEATURE_EXCHANGE_API_SOURCE` | String | `mongodb` | Data source for the Exchange API: `mongodb` or `file` | + +### Exchange Store Configuration + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `EXCHANGE_STORE_MAX_MESSAGES` | Number | `5000` | Max exchanges stored per device | +| `EXCHANGE_STORE_TTL_SECONDS` | Number | `86400` | TTL in seconds for auto-removal (24h) | +| `EXCHANGE_STORE_READ_TIMEOUT_MS` | Number | `5000` | Query timeout before returning partial results | + +### SSE Configuration + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `EXCHANGE_STORE_SSE_INACTIVITY_TIMEOUT_MS` | Number | `300000` | Close SSE connection after 5 min of inactivity | +| `EXCHANGE_STORE_SSE_ABSOLUTE_TIMEOUT_MS` | Number | `7200000` | Close SSE connection after 2 hours regardless | +| `EXCHANGE_STORE_SSE_MAX_CONNECTIONS_PER_DEVICE` | Number | `5` | Max concurrent SSE connections per device | +| `EXCHANGE_STORE_SSE_RETRY_INTERVAL_MS` | Number | `3000` | Retry interval sent to clients for reconnection | + +### Retry Policy + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `EXCHANGE_STORE_RETRY_MAX_ATTEMPTS` | Number | `3` | Max retries for critical message persistence | +| `EXCHANGE_STORE_RETRY_BASE_DELAY_MS` | Number | `1000` | Base delay for exponential backoff (1s, 2s, 4s) | + +### Example .env Addition + +```dotenv +# TR-069 Exchange Viewer - Feature Flags +FEATURE_EXCHANGE_PERSISTENCE=true +FEATURE_SSE_ENABLED=true +FEATURE_EXCHANGE_API_SOURCE=mongodb + +# TR-069 Exchange Viewer - Storage +EXCHANGE_STORE_MAX_MESSAGES=5000 +EXCHANGE_STORE_TTL_SECONDS=86400 +EXCHANGE_STORE_READ_TIMEOUT_MS=5000 + +# TR-069 Exchange Viewer - SSE +EXCHANGE_STORE_SSE_INACTIVITY_TIMEOUT_MS=300000 +EXCHANGE_STORE_SSE_ABSOLUTE_TIMEOUT_MS=7200000 +EXCHANGE_STORE_SSE_MAX_CONNECTIONS_PER_DEVICE=5 +EXCHANGE_STORE_SSE_RETRY_INTERVAL_MS=3000 + +# TR-069 Exchange Viewer - Retry Policy +EXCHANGE_STORE_RETRY_MAX_ATTEMPTS=3 +EXCHANGE_STORE_RETRY_BASE_DELAY_MS=1000 +``` + +--- + +## Feature Flags Usage and Toggle Procedures + +The system uses three feature flags for gradual rollout and safe rollback. All flags can be toggled at runtime by changing environment variables and restarting the application. + +### FEATURE_EXCHANGE_PERSISTENCE + +Controls whether CWMP exchanges are written to MongoDB. + +| Value | Behavior | +|-------|----------| +| `true` (default) | Exchanges are persisted to MongoDB for traced devices | +| `false` | No writes to MongoDB; CWMP sessions continue unaffected | + +**When to disable:** +- MongoDB is experiencing high latency or unavailability +- Storage exhaustion on the MongoDB server +- You need to temporarily reduce load on the database + +**Toggle procedure:** +```bash +# Disable persistence +export FEATURE_EXCHANGE_PERSISTENCE=false +# Restart the application +docker compose restart app +``` + +> Note: Disabling persistence means no new exchanges are stored. Existing exchanges remain queryable until TTL expires. + +### FEATURE_SSE_ENABLED + +Controls the real-time streaming endpoint. + +| Value | Behavior | +|-------|----------| +| `true` (default) | SSE endpoint is available at `/api/v1/tr069-trace/:deviceId/exchanges/stream` | +| `false` | SSE endpoint returns 503; frontend falls back to REST polling every 5 seconds | + +**When to disable:** +- High number of concurrent SSE connections causing resource exhaustion +- Network infrastructure (proxy/load balancer) not supporting long-lived connections +- Debugging connectivity issues + +**Toggle procedure:** +```bash +# Disable SSE streaming +export FEATURE_SSE_ENABLED=false +# Restart the application +docker compose restart app +``` + +> Note: The frontend automatically falls back to REST polling (5-second interval) when SSE is unavailable. No user action required. + +### FEATURE_EXCHANGE_API_SOURCE + +Controls where the Exchange API reads data from. + +| Value | Behavior | +|-------|----------| +| `mongodb` (default) | API reads from the `cwmp_exchanges` MongoDB collection | +| `file` | API reads from the legacy file-based trace system | + +**When to switch to `file`:** +- MongoDB data is corrupted or inconsistent +- Rollback to the previous system during migration +- Comparing outputs between old and new systems + +**Toggle procedure:** +```bash +# Fallback to file-based system +export FEATURE_EXCHANGE_API_SOURCE=file +# Restart the application +docker compose restart app +``` + +### Recommended Rollout Sequence + +1. **Phase 1 — Dual-write** (default state): + - `FEATURE_EXCHANGE_PERSISTENCE=true` + - `FEATURE_SSE_ENABLED=true` + - `FEATURE_EXCHANGE_API_SOURCE=mongodb` + - Old file-based system continues writing in parallel (dual-write) + +2. **Phase 2 — Validate**: + - Compare file-based and MongoDB exchange counts + - Monitor health check for discrepancies + - Verify SSE connections are stable + +3. **Phase 3 — Full cutover**: + - Remove file-based writes from `CwmpLoggerMiddleware` + - `FEATURE_EXCHANGE_API_SOURCE=mongodb` (permanently) + +### Emergency Rollback + +To fully disable the exchange viewer feature without stopping the application: + +```bash +export FEATURE_EXCHANGE_PERSISTENCE=false +export FEATURE_SSE_ENABLED=false +export FEATURE_EXCHANGE_API_SOURCE=file +docker compose restart app +``` + +This restores pre-feature behavior: no MongoDB writes, no SSE connections, and the API reads from the legacy file system. + +--- + +## Health Check Endpoint + +### Endpoint + +``` +GET /api/v1/health +``` + +The existing health check endpoint (`/api/v1/health`) reports on the overall application status including MongoDB connectivity and `cwmp_exchanges` index status. + +### Response Format + +```json +{ + "status": "OK", + "version": "1.0.0", + "indexes": { + "cwmp_exchanges": { + "allPresent": true, + "indexes": [ + { "name": "idx_deviceId_timestamp", "present": true }, + { "name": "idx_deviceId_sequenceNumber", "present": true }, + { "name": "idx_createdAt_ttl", "present": true } + ] + } + } +} +``` + +### Status Codes + +| Status | Code | Meaning | +|--------|------|---------| +| OK | 200 | MongoDB connected, all indexes present | +| KO | 503 | MongoDB unreachable or critical failure | + +### Monitoring Recommendations + +| Metric | Threshold | Action | +|--------|-----------|--------| +| Health check status `KO` | Any occurrence | Investigate MongoDB connectivity | +| Missing indexes (`allPresent: false`) | Any occurrence | Re-run migration: `node ace migration:run` | +| MongoDB ping latency | > 100ms p95 | Check MongoDB server load | +| Active SSE connections | > 100 total | Consider scaling or limiting connections | + +### Integration with Monitoring Tools + +The health check endpoint can be used with standard monitoring tools: + +```bash +# Simple liveness check (Docker HEALTHCHECK) +curl -f http://localhost:8080/api/v1/health || exit 1 + +# Kubernetes readiness probe +livenessProbe: + httpGet: + path: /api/v1/health + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 30 + +# Nagios/Zabbix check +curl -s http://localhost:8080/api/v1/health | jq -e '.status == "OK"' +``` + +--- + +## API Endpoints + +The feature exposes three endpoints under the `/api/v1` prefix: + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/tr069-trace/:deviceId/exchanges` | Retrieve exchanges (paginated) | +| DELETE | `/api/v1/tr069-trace/:deviceId/exchanges` | Purge all exchanges for a device | +| GET | `/api/v1/tr069-trace/:deviceId/exchanges/stream` | SSE endpoint for real-time streaming | + +--- + +## Reverse Proxy Configuration + +If running behind a reverse proxy (nginx, Traefik, etc.), configure it to support SSE: + +### Nginx + +```nginx +location /api/v1/tr069-trace/ { + proxy_pass http://app:8080; + proxy_http_version 1.1; + proxy_set_header Connection ''; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 7200s; # Match absolute timeout (2h) + chunked_transfer_encoding off; +} +``` + +### Traefik + +```yaml +http: + middlewares: + sse-headers: + headers: + customResponseHeaders: + X-Accel-Buffering: "no" + services: + adam-acs: + loadBalancer: + responseForwarding: + flushInterval: "0ms" +``` + +--- + +## Docker Deployment + +No additional Docker services are needed. The feature uses the existing MongoDB instance. + +```yaml +# docker-compose.yml (relevant section) +services: + app: + environment: + - FEATURE_EXCHANGE_PERSISTENCE=true + - FEATURE_SSE_ENABLED=true + - FEATURE_EXCHANGE_API_SOURCE=mongodb + depends_on: + - mongodb +``` + +Ensure the MongoDB container has sufficient storage for exchange data (see [Storage Considerations](#storage-considerations)). diff --git a/docs/migration/tr069-exchange-viewer.md b/docs/migration/tr069-exchange-viewer.md new file mode 100644 index 0000000..ec5465f --- /dev/null +++ b/docs/migration/tr069-exchange-viewer.md @@ -0,0 +1,592 @@ +# Guide de migration : TR-069 Exchange Viewer + +## Résumé + +Ce guide décrit la migration du système de trace TR-069 depuis le stockage fichier (logs `.log` rotatifs) vers le stockage structuré MongoDB (`cwmp_exchanges`). La migration suit une approche en 3 phases pour garantir zéro perte de données et un rollback possible à chaque étape. + +| Phase | Durée | Description | Risque | +|-------|-------|-------------|--------| +| Phase 1 — Dual-Write | Semaines 1-2 | Écriture simultanée fichiers + MongoDB | Faible | +| Phase 2 — Read from MongoDB | Semaines 3-4 | API lit depuis MongoDB, fichiers en backup | Moyen | +| Phase 3 — Deprecate Files | Semaine 5+ | Suppression du système fichier | Irréversible | + +--- + +## Prérequis + +### Infrastructure + +- MongoDB 5.0+ (pas besoin de replica set — le système utilise un EventEmitter in-process, pas de change streams) +- Espace disque MongoDB : prévoir ~500 Mo pour 10 000 devices actifs avec 5 000 messages max chacun +- L'application adam-acs doit être redémarrée après la migration de base + +### Variables d'environnement + +```bash +# Feature flags (déjà présents dans config/exchange_store.ts) +FEATURE_EXCHANGE_PERSISTENCE=true # Active les écritures MongoDB +FEATURE_SSE_ENABLED=true # Active le streaming SSE +FEATURE_EXCHANGE_API_SOURCE=mongodb # Source des données API (mongodb | file) + +# Configuration optionnelle +EXCHANGE_STORE_MAX_MESSAGES=5000 # Cap messages par device +EXCHANGE_STORE_TTL_SECONDS=86400 # TTL 24h +EXCHANGE_STORE_READ_TIMEOUT_MS=5000 # Timeout requêtes API +``` + +--- + +## Phase 1 : Dual-Write (Semaines 1-2) + +### Objectif + +Écrire simultanément dans les deux systèmes (fichiers ET MongoDB) pour valider la fiabilité du nouveau système sans risque de perte. + +### 1.1 Exécuter la migration MongoDB + +La migration crée la collection `cwmp_exchanges` et ses index : + +```bash +# Exécuter la migration +node ace migration:run --connection=mongodb +``` + +Cela exécute `database/mongo_migrations/011_create_cwmp_exchanges_collection.ts` qui crée : +- Collection `cwmp_exchanges` avec 3 index : + - `{ deviceId: 1, timestamp: 1 }` — requêtes par device + - `{ deviceId: 1, sequenceNumber: 1 }` (unique) — numérotation séquentielle et reconnexion SSE + - `{ createdAt: 1 }` (TTL: 86400s) — nettoyage automatique après 24h +- Collection `cwmp_exchange_counters` — compteurs atomiques de séquence + +### 1.2 Vérifier les index + +```bash +# Via mongosh +mongosh --eval " + db.cwmp_exchanges.getIndexes().forEach(idx => { + print(idx.name + ': ' + JSON.stringify(idx.key)) + }) +" +``` + +Résultat attendu : +``` +_id_: {"_id":1} +idx_deviceId_timestamp: {"deviceId":1,"timestamp":1} +idx_deviceId_sequenceNumber: {"deviceId":1,"sequenceNumber":1} +idx_createdAt_ttl: {"createdAt":1} +``` + +### 1.3 Activer le dual-write + +Le dual-write est déjà implémenté dans `CwmpLoggerMiddleware`. Vérifier les feature flags : + +```bash +# .env — Phase 1 +FEATURE_EXCHANGE_PERSISTENCE=true +FEATURE_SSE_ENABLED=true +FEATURE_EXCHANGE_API_SOURCE=file # GARDER 'file' en Phase 1 +``` + +Le middleware écrit dans les deux systèmes : + +```typescript +// app/middleware/cwmp_logger_middleware.ts (extrait) +if (isTraced && deviceId) { + // Nouveau système : MongoDB (fire-and-forget) + exchangeStore.persistExchange({ deviceId, direction, sessionId, rawXml }) + .catch(() => {}) + + // Ancien système : fichiers (gardé pour rollback) + traceService.writeExchange(deviceId, direction, sessionId, prettyXml) +} +``` + +### 1.4 Redémarrer l'application + +```bash +docker compose build app +docker compose up -d app +``` + +### 1.5 Vérifier le fonctionnement + +Activer le trace sur un device et déclencher un Inform : + +```bash +# Vérifier que MongoDB reçoit les données +mongosh --eval "db.cwmp_exchanges.countDocuments({})" + +# Vérifier les derniers échanges +mongosh --eval " + db.cwmp_exchanges.find({}).sort({timestamp: -1}).limit(3).forEach(doc => { + print(doc.deviceId + ' | ' + doc.direction + ' | ' + doc.cwmpMethod) + }) +" +``` + +### 1.6 Vérification de cohérence (Dual-Write) + +Pendant la Phase 1, vérifier régulièrement que les deux systèmes restent synchronisés : + +#### Script de comparaison + +```bash +#!/bin/bash +# scripts/verify_dual_write.sh +# Compare le nombre d'échanges entre fichiers et MongoDB pour chaque device tracé + +DEVICE_ID=$1 + +if [ -z "$DEVICE_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +# Compteur fichier (nombre de séparateurs '═' = nombre d'échanges) +FILE_COUNT=$(grep -c '═════' "logs/tr069/${DEVICE_ID}.log" 2>/dev/null || echo "0") + +# Compteur MongoDB +MONGO_COUNT=$(mongosh --quiet --eval "db.cwmp_exchanges.countDocuments({deviceId: '${DEVICE_ID}'})") + +echo "Device: ${DEVICE_ID}" +echo " Fichiers : ${FILE_COUNT} échanges" +echo " MongoDB : ${MONGO_COUNT} échanges" + +if [ "$FILE_COUNT" != "$MONGO_COUNT" ]; then + DIFF=$((FILE_COUNT - MONGO_COUNT)) + echo " ⚠️ Écart : ${DIFF} messages" + if [ "${DIFF#-}" -gt $((FILE_COUNT / 20)) ]; then + echo " 🚨 Écart > 5% — Investigation requise !" + fi +else + echo " ✅ Cohérent" +fi +``` + +#### Critères d'acceptation Phase 1 + +| Critère | Seuil | Action si dépassé | +|---------|-------|-------------------| +| Écart fichier/MongoDB | < 5% | Vérifier les logs d'erreur ExchangeStore | +| Erreurs de persistance | < 0.1% | Vérifier la connectivité MongoDB | +| Latence MongoDB p95 | < 100ms | Vérifier la charge et les index | +| Messages perdus (critiques) | 0 après retries | Vérifier les logs de retry exhaustion | + +#### Durée recommandée + +Maintenir le dual-write pendant **minimum 1 semaine** avec des vérifications quotidiennes. Passer en Phase 2 uniquement si les critères ci-dessus sont respectés de manière constante. + +--- + +## Phase 2 : Read from MongoDB (Semaines 3-4) + +### Objectif + +Basculer la lecture des données vers MongoDB tout en gardant les fichiers en écriture backup. + +### 2.1 Changer la source de lecture API + +```bash +# .env — Phase 2 +FEATURE_EXCHANGE_PERSISTENCE=true +FEATURE_SSE_ENABLED=true +FEATURE_EXCHANGE_API_SOURCE=mongodb # ← Changer de 'file' à 'mongodb' +``` + +### 2.2 Redémarrer l'application + +```bash +docker compose restart app +``` + +### 2.3 Vérifier le fonctionnement + +```bash +# Vérifier que l'API retourne des données depuis MongoDB +curl -s "http://localhost:8080/api/v1/tr069-trace//exchanges?limit=5" | \ + python3 -c " +import sys, json +data = json.load(sys.stdin) +print(f\"Source: MongoDB\") +print(f\"Exchanges: {len(data.get('exchanges', []))} messages\") +print(f\"Metadata: {data.get('metadata', {})}\") +" +``` + +### 2.4 Vérifier le SSE + +```bash +# Ouvrir une connexion SSE (ctrl+C pour arrêter) +curl -N -H "Accept: text/event-stream" \ + "http://localhost:8080/api/v1/tr069-trace//exchanges/stream" +``` + +Déclencher un Inform sur le device tracé et vérifier qu'un événement SSE arrive en temps réel. + +### 2.5 Vérifier le fallback + +Si MongoDB est indisponible, le frontend doit retomber sur les fichiers : + +```typescript +// Logique dans DeviceDetail.vue +const exchanges = await api.getExchanges(deviceId) + .catch(() => legacyService.readExchanges(deviceId)) +``` + +### 2.6 Critères de passage Phase 2 → Phase 3 + +| Critère | Seuil | Vérifié | +|---------|-------|---------| +| API MongoDB fonctionne sans erreur | 48h continues | ☐ | +| SSE pousse les événements en < 500ms | p95 vérifié | ☐ | +| Aucune perte de message critique | 0 sur 48h | ☐ | +| Reconnexion SSE (Last-Event-ID) valide | Testé manuellement | ☐ | +| Purge (DELETE) fonctionne | 204 retourné | ☐ | + +--- + +## Phase 3 : Deprecate Old System (Semaine 5+) + +### Objectif + +Supprimer le système fichier une fois que MongoDB est validé en production. + +### 3.1 Supprimer le dual-write du middleware + +Dans `app/middleware/cwmp_logger_middleware.ts`, retirer les appels au système fichier : + +```typescript +// AVANT (dual-write) +if (isTraced && deviceId) { + exchangeStore.persistExchange({ deviceId, direction, sessionId, rawXml }).catch(() => {}) + traceService.writeExchange(deviceId, direction, sessionId, prettyXml) // ← SUPPRIMER +} + +// APRÈS (MongoDB uniquement) +if (isTraced && deviceId) { + exchangeStore.persistExchange({ deviceId, direction, sessionId, rawXml }).catch(() => {}) +} +``` + +### 3.2 Supprimer les dépendances obsolètes + +1. Retirer l'import `xml-formatter` du middleware (le formatage n'est plus nécessaire côté persistance) +2. Supprimer les appels à `Tr069TraceService.writeExchange()` dans le middleware +3. **NE PAS** supprimer `Tr069TraceService` lui-même — il est encore utilisé pour gérer l'état trace (start/stop) + +### 3.3 Nettoyer les fichiers de logs + +```bash +# Archiver les anciens logs avant suppression +tar -czf logs/tr069-archive-$(date +%Y%m%d).tar.gz logs/tr069/ +rm -rf logs/tr069/*.log* +``` + +### 3.4 Mise à jour de la configuration + +```bash +# .env — Phase 3 (état final) +FEATURE_EXCHANGE_PERSISTENCE=true +FEATURE_SSE_ENABLED=true +FEATURE_EXCHANGE_API_SOURCE=mongodb +``` + +### 3.5 Vérification finale + +```bash +# Vérifier qu'aucun fichier .log n'est créé +ls -la logs/tr069/ + +# Vérifier que le système fonctionne uniquement via MongoDB +curl -s "http://localhost:8080/api/v1/health/tr069-exchanges" | python3 -m json.tool +``` + +--- + +## Procédures de Rollback + +### Rollback Phase 1 → Système fichier seul + +**Situation :** MongoDB pose problème, il faut revenir au système fichier uniquement. + +```bash +# 1. Désactiver la persistance MongoDB +FEATURE_EXCHANGE_PERSISTENCE=false + +# 2. Redémarrer +docker compose restart app + +# 3. Vérifier que les fichiers continuent d'être écrits +ls -la logs/tr069/ +``` + +**Impact :** Les données déjà en MongoDB restent accessibles mais ne sont plus alimentées. Le SSE ne fonctionne plus. L'UI retombe sur les fichiers. + +**Temps de rollback :** < 2 minutes (changement d'env + restart) + +### Rollback Phase 2 → Phase 1 (Dual-Write) + +**Situation :** L'API MongoDB a des problèmes, revenir au dual-write avec lecture fichier. + +```bash +# 1. Changer la source API +FEATURE_EXCHANGE_API_SOURCE=file + +# 2. Redémarrer +docker compose restart app +``` + +**Impact :** L'API lit les fichiers au lieu de MongoDB. Le dual-write continue. Aucune perte de données. + +**Temps de rollback :** < 2 minutes + +### Rollback Phase 3 → Phase 2 + +**Situation :** Après suppression du dual-write, MongoDB pose problème. + +**⚠️ ATTENTION : Ce rollback nécessite un redéploiement du code.** + +1. Revert du commit qui a supprimé le dual-write dans `CwmpLoggerMiddleware` +2. Redéployer l'application +3. Vérifier que les fichiers sont à nouveau écrits + +```bash +# Identifier le commit de suppression du dual-write +git log --oneline -- app/middleware/cwmp_logger_middleware.ts + +# Revert +git revert +docker compose build app +docker compose up -d app +``` + +**Impact :** Nécessite un redéploiement (~5 minutes). Les échanges entre la suppression et le rollback ne sont PAS dans les fichiers. + +**Temps de rollback :** 5-10 minutes + +### Rollback SSE uniquement + +**Situation :** Le SSE cause des problèmes (connexions, mémoire) mais MongoDB est stable. + +```bash +# Désactiver uniquement le SSE +FEATURE_SSE_ENABLED=false + +# Redémarrer +docker compose restart app +``` + +**Impact :** Le frontend bascule automatiquement en REST polling (toutes les 5 secondes). Les données MongoDB sont toujours disponibles. Pas de perte. + +### Rollback complet (retour système fichier) + +**Situation :** Problème majeur, tout désactiver. + +```bash +# Tout désactiver +FEATURE_EXCHANGE_PERSISTENCE=false +FEATURE_SSE_ENABLED=false +FEATURE_EXCHANGE_API_SOURCE=file + +# Redémarrer +docker compose restart app +``` + +**Impact :** Retour complet au système fichier. MongoDB n'est plus utilisé. L'UI affiche les logs comme avant la migration. + +--- + +## Migration des données historiques (optionnel) + +Si vous souhaitez importer les logs fichier existants dans MongoDB, utilisez le script ci-dessous. Cette étape est **optionnelle** car les anciens logs disparaîtront naturellement via le TTL 24h. + +### Script d'import + +```typescript +// scripts/migrate_file_logs_to_mongodb.ts +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { getMongoDb } from '#providers/mongodb_provider' + +const LOG_DIR = join(process.cwd(), 'logs', 'tr069') +const SEPARATOR = '═'.repeat(80) + +interface ParsedEntry { + deviceId: string + timestamp: string + direction: 'CPE→ACS' | 'ACS→CPE' + sessionId: string + rawXml: string +} + +function parseLogFile(deviceId: string, content: string): ParsedEntry[] { + const entries: ParsedEntry[] = [] + const blocks = content.split(SEPARATOR).filter((b) => b.trim()) + + for (let i = 0; i < blocks.length - 1; i += 2) { + const header = blocks[i].trim() + const xml = blocks[i + 1]?.trim() || '' + + // Parse header: [2025-01-15T10:30:00.123Z] CPE→ACS | 10.0.0.1:54321->192.168.1.1:8080 + const headerMatch = header.match( + /\[(.+?)\]\s+(CPE→ACS|ACS→CPE)\s+\|\s+(.+)/ + ) + if (!headerMatch) continue + + entries.push({ + deviceId, + timestamp: headerMatch[1], + direction: headerMatch[2] as 'CPE→ACS' | 'ACS→CPE', + sessionId: headerMatch[3], + rawXml: xml, + }) + } + + return entries +} + +async function migrateAll(): Promise { + const db = getMongoDb() + const collection = db.collection('cwmp_exchanges') + const counters = db.collection('cwmp_exchange_counters') + + const files = readdirSync(LOG_DIR).filter((f) => f.endsWith('.log')) + let totalMigrated = 0 + + for (const file of files) { + const deviceId = file.replace('.log', '') + const content = readFileSync(join(LOG_DIR, file), 'utf-8') + const entries = parseLogFile(deviceId, content) + + if (entries.length === 0) continue + + // Get or initialize sequence counter + const counterResult = await counters.findOneAndUpdate( + { _id: deviceId as any }, + { $inc: { seq: entries.length } }, + { upsert: true, returnDocument: 'after' } + ) + const startSeq = (counterResult!.seq as number) - entries.length + 1 + + // Build documents + const docs = entries.map((entry, idx) => ({ + ...entry, + sequenceNumber: startSeq + idx, + cwmpMethod: extractMethod(entry.rawXml), + createdAt: new Date(entry.timestamp), + })) + + // Bulk insert + await collection.insertMany(docs, { ordered: false }).catch((err) => { + console.warn(` ⚠️ Duplicates ignored for ${deviceId}: ${err.message}`) + }) + + totalMigrated += docs.length + console.log(` ✅ ${deviceId}: ${docs.length} échanges migrés`) + } + + console.log(`\n📊 Total: ${totalMigrated} échanges migrés depuis ${files.length} fichiers`) +} + +function extractMethod(xml: string): string { + if (!xml || xml.trim() === '') return 'Empty' + const match = xml.match(/]/) + return match ? match[1] : 'Unknown' +} + +migrateAll().catch(console.error) +``` + +### Exécution + +```bash +# Depuis la racine du projet +npx ts-node scripts/migrate_file_logs_to_mongodb.ts +``` + +**Notes :** +- Le script est idempotent : les doublons sont ignorés (grâce à l'index unique deviceId + sequenceNumber) +- Les documents importés auront un `createdAt` basé sur le timestamp du log, donc ils seront supprimés par le TTL 24h si ils datent de plus de 24 heures +- Pour conserver les données historiques plus longtemps, ajustez temporairement `EXCHANGE_STORE_TTL_SECONDS` + +--- + +## Vérifications post-migration + +### Checklist + +| # | Vérification | Commande | Attendu | +|---|-------------|----------|---------| +| 1 | Collection existe | `db.cwmp_exchanges.stats()` | Collection présente | +| 2 | Index créés | `db.cwmp_exchanges.getIndexes()` | 4 index (dont _id) | +| 3 | TTL actif | `db.cwmp_exchanges.getIndexes()` | `expireAfterSeconds: 86400` | +| 4 | Compteurs fonctionnent | `db.cwmp_exchange_counters.find()` | Entrées par device | +| 5 | API accessible | `GET /api/v1/tr069-trace/:id/exchanges` | 200 avec données | +| 6 | SSE connecte | `GET /api/v1/tr069-trace/:id/exchanges/stream` | Event stream ouvert | +| 7 | Health check | `GET /api/v1/health/tr069-exchanges` | `status: "healthy"` | + +### Commandes de diagnostic + +```bash +# Statistiques collection +mongosh --eval " + const stats = db.cwmp_exchanges.stats() + print('Documents:', stats.count) + print('Taille:', (stats.size / 1024 / 1024).toFixed(2), 'Mo') + print('Index size:', (stats.totalIndexSize / 1024 / 1024).toFixed(2), 'Mo') +" + +# Vérifier le TTL monitor +mongosh --eval " + db.cwmp_exchanges.aggregate([ + { \$group: { _id: null, oldest: { \$min: '\$createdAt' }, newest: { \$max: '\$createdAt' } } } + ]).forEach(doc => { + print('Plus ancien:', doc.oldest) + print('Plus récent:', doc.newest) + const ageH = (Date.now() - new Date(doc.oldest).getTime()) / 3600000 + print('Âge max:', ageH.toFixed(1), 'heures (devrait être < 24h)') + }) +" + +# Vérifier les compteurs de séquence +mongosh --eval " + db.cwmp_exchange_counters.find().sort({seq: -1}).limit(5).forEach(doc => { + print(doc._id + ': seq=' + doc.seq) + }) +" +``` + +--- + +## Résumé des feature flags + +| Flag | Phase 1 | Phase 2 | Phase 3 | Rollback | +|------|---------|---------|---------|----------| +| `FEATURE_EXCHANGE_PERSISTENCE` | `true` | `true` | `true` | `false` | +| `FEATURE_SSE_ENABLED` | `true` | `true` | `true` | `false` | +| `FEATURE_EXCHANGE_API_SOURCE` | `file` | `mongodb` | `mongodb` | `file` | + +--- + +## FAQ + +### Que se passe-t-il si MongoDB tombe pendant le dual-write ? + +Les écritures MongoDB échouent silencieusement (fire-and-forget). Le système fichier continue normalement. Les messages critiques sont retentés 3 fois avec backoff exponentiel (1s, 2s, 4s) avant d'être abandonnés. La session CWMP n'est jamais interrompue. + +### Le TTL va-t-il supprimer des données pendant la migration ? + +Oui. Le TTL de 24h s'applique dès la création de l'index. Si vous importez des logs historiques de plus de 24h, ils seront supprimés par le TTL monitor MongoDB (qui tourne ~toutes les 60 secondes). Augmentez temporairement `EXCHANGE_STORE_TTL_SECONDS` si nécessaire. + +### Puis-je migrer les données fichier vers MongoDB ? + +Oui, voir la section "Migration des données historiques". C'est optionnel car le système fonctionne immédiatement avec les nouveaux échanges. Les anciens logs fichier restent lisibles via `FEATURE_EXCHANGE_API_SOURCE=file`. + +### Combien d'espace MongoDB est nécessaire ? + +Estimation : ~1 Ko par échange (métadonnées) + taille du XML brut (typiquement 2-50 Ko). Avec le cap de 5 000 messages par device et le TTL 24h, la croissance est bornée. Pour 100 devices tracés simultanément : ~500 Mo maximum. + +### Le dual-write a-t-il un impact sur les performances CWMP ? + +Non. Les écritures MongoDB sont fire-and-forget (`catch(() => {})`) et ne bloquent jamais le traitement CWMP. En cas de lenteur MongoDB, le middleware continue sans attendre. diff --git a/docs/monitoring/alerting-rules.md b/docs/monitoring/alerting-rules.md new file mode 100644 index 0000000..b9ac094 --- /dev/null +++ b/docs/monitoring/alerting-rules.md @@ -0,0 +1,396 @@ +# Alerting Rules — TR-069 Exchange Viewer + +## Overview + +Ce document décrit les règles d'alerte configurées pour le système TR-069 Exchange Viewer. Chaque règle définit un seuil critique, les conditions de déclenchement, l'impact attendu et la procédure d'escalade associée. + +Les métriques sont exposées via le endpoint `/api/v1/health/tr069-exchanges` et les logs structurés JSON. + +--- + +## Alert 1: SSE Active Connections > 100 + +### Seuil + +| Métrique | Condition | Sévérité | +|----------|-----------|----------| +| `sse.active_connections` | > 100 connexions simultanées | **Warning** | +| `sse.active_connections` | > 200 connexions simultanées | **Critical** | + +### Description + +Un nombre élevé de connexions SSE simultanées indique une charge serveur importante. Chaque connexion SSE maintient un socket HTTP ouvert et un listener EventEmitter en mémoire. Au-delà de 100 connexions, le serveur risque une dégradation de performance (latence accrue, consommation mémoire excessive). + +### Causes possibles + +- Pic d'activité : de nombreux opérateurs tracent des devices simultanément +- Fuite de connexions : les clients ne ferment pas correctement les connexions EventSource (navigation sans cleanup) +- Reconnection storm : perte réseau transitoire provoquant la reconnexion simultanée de tous les clients +- Bug client : boucle de reconnexion infinie (EventSource retry sans backoff) + +### Impact + +- Augmentation de la consommation mémoire du processus Node.js +- Latence accrue sur les push SSE (chaque nouveau message est dispatché à N listeners) +- Risque d'épuisement des file descriptors (limite OS) +- Dégradation possible du traitement CWMP si le thread principal est saturé + +### Détection + +```json +// GET /api/v1/health/tr069-exchanges +{ + "sse": { + "active_connections": 142, + "reconnections_1h": 56 + } +} +``` + +Logs structurés : +```json +{ "level": "warn", "msg": "SSE connection count high", "active_connections": 142, "threshold": 100 } +``` + +### Procédure de résolution + +1. **Vérifier** le nombre de connexions actives via le health check endpoint +2. **Identifier** les devices avec le plus de connexions (logs : `sse.connect` events par deviceId) +3. **Vérifier** si des connexions zombies existent (connexions ouvertes sans activité > 5 minutes) +4. **Appliquer** les timeouts serveur : les connexions inactives sont fermées après 5 min, les absolues après 2h +5. **Si le problème persiste** : redémarrer le service pour forcer la fermeture de toutes les connexions SSE + +### Escalade + +| Durée | Action | +|-------|--------| +| 0-5 min | Monitoring automatique, pas d'action | +| 5-15 min (Warning) | Vérifier les logs, identifier la cause | +| > 15 min (Warning) | Notifier l'équipe Dev via Slack #adam-acs-alerts | +| Immédiat (Critical > 200) | Notifier l'équipe Dev + Ops, envisager un restart | +| > 30 min (Critical) | Activer le feature flag `FEATURE_SSE_ENABLED=false` (fallback REST polling) | + +--- + +## Alert 2: MongoDB Write Latency p95 > 100ms + +### Seuil + +| Métrique | Condition | Sévérité | +|----------|-----------|----------| +| `mongodb.write_latency_p95` | > 100ms | **Warning** | +| `mongodb.write_latency_p95` | > 500ms | **Critical** | +| `mongodb.write_latency_p99` | > 1000ms | **Critical** | + +### Description + +La latence d'écriture MongoDB au percentile 95 reflète la performance de persistance des échanges CWMP. Au-delà de 100ms (p95), les écritures commencent à s'accumuler, ce qui peut provoquer un retard dans la diffusion SSE et une pression mémoire sur le processus. + +### Causes possibles + +- Charge MongoDB élevée (autres opérations concurrentes, compaction, journalisation) +- Index manquant ou dégradé sur la collection `cwmp_exchanges` +- Disque I/O saturé sur le serveur MongoDB +- Collection `cwmp_exchanges` trop volumineuse (TTL cleanup en retard) +- Locks MongoDB : opérations bulk ou migrations en cours + +### Impact + +- Retard dans la diffusion SSE des nouveaux échanges (latence end-to-end augmentée) +- Activation des retries pour les messages critiques (backoff 1s, 2s, 4s) +- En cas de timeout persistant : perte potentielle de messages non-critiques (Empty, Unknown) +- Le traitement CWMP n'est jamais impacté (fire-and-forget) + +### Détection + +Logs structurés : +```json +{ "level": "warn", "msg": "MongoDB write slow", "collection": "cwmp_exchanges", "duration_ms": 145, "deviceId": "abc123", "cwmpMethod": "Inform" } +``` + +Métriques agrégées (via logs ou Prometheus) : +``` +mongodb_write_latency_ms{collection="cwmp_exchanges", quantile="0.95"} 145 +mongodb_write_latency_ms{collection="cwmp_exchanges", quantile="0.99"} 320 +``` + +### Procédure de résolution + +1. **Vérifier** l'état de MongoDB : `db.serverStatus()`, `db.currentOp()` +2. **Vérifier** les index de `cwmp_exchanges` : + ```bash + mongosh --eval "db.cwmp_exchanges.getIndexes()" + ``` +3. **Vérifier** la taille de la collection et le nombre de documents : + ```bash + mongosh --eval "db.cwmp_exchanges.stats()" + ``` +4. **Vérifier** le TTL cleanup (voir Alert 4) +5. **Si les index sont manquants** : recréer via la migration ou manuellement : + ```bash + mongosh --eval "db.cwmp_exchanges.createIndex({deviceId:1, timestamp:1})" + mongosh --eval "db.cwmp_exchanges.createIndex({deviceId:1, sequenceNumber:1}, {unique:true})" + mongosh --eval "db.cwmp_exchanges.createIndex({createdAt:1}, {expireAfterSeconds:86400})" + ``` +6. **Si la charge est transitoire** : attendre la stabilisation (compaction terminée, etc.) + +### Escalade + +| Durée | Action | +|-------|--------| +| 0-5 min | Monitoring automatique | +| 5-15 min (Warning) | Vérifier les index et la charge MongoDB | +| > 15 min (Warning) | Notifier l'équipe Dev | +| Immédiat (Critical > 500ms) | Notifier Dev + DBA | +| > 30 min (Critical) | Envisager `FEATURE_EXCHANGE_PERSISTENCE=false` pour délester MongoDB | + +--- + +## Alert 3: Message Loss Rate > 0.1% + +### Seuil + +| Métrique | Condition | Sévérité | +|----------|-----------|----------| +| `exchange.loss_rate` | > 0.1% (1 message sur 1000) | **Critical** | +| `exchange.loss_rate` | > 1.0% (1 message sur 100) | **Urgent** | + +### Description + +Le taux de perte de messages représente le pourcentage de messages CWMP capturés par le middleware mais non persistés en MongoDB (après épuisement des retries pour les critiques, ou discard immédiat pour les non-critiques). Un taux > 0.1% indique des défaillances critiques du backend de persistance. + +### Causes possibles + +- Défaillance MongoDB : connexion perdue, timeout, disque plein +- Retry exhaustion : 3 retries échoués consécutivement pour les messages critiques +- Erreur dans le service `ExchangeStoreService` (bug, exception non catchée) +- Saturation de la collection (cap à 5000 messages avec suppression simultanée) +- Problème réseau entre l'application et MongoDB + +### Impact + +- Trous dans l'historique des échanges visibles dans le Sequence Viewer +- Les opérateurs voient des séquences incomplètes, rendant le debug difficile +- Les messages critiques (Inform, SetParameterValues) perdus représentent une perte d'observabilité sur les actions CPE + +### Détection + +Logs structurés : +```json +{ "level": "error", "msg": "Exchange persistence failed after retries", "deviceId": "abc123", "cwmpMethod": "Inform", "retries": 3, "error": "MongoServerError: connection timeout" } +{ "level": "warn", "msg": "Non-critical exchange discarded", "deviceId": "abc123", "cwmpMethod": "Empty", "error": "MongoServerError: connection timeout" } +``` + +Calcul du taux de perte : +``` +loss_rate = (messages_discarded / messages_captured) * 100 +``` + +### Procédure de résolution + +1. **Vérifier** la connectivité MongoDB : health check endpoint, `mongosh` ping +2. **Vérifier** les logs d'erreur pour identifier le pattern de défaillance +3. **Vérifier** l'espace disque disponible sur le serveur MongoDB +4. **Vérifier** que le TTL cleanup fonctionne (pas d'accumulation illimitée) +5. **Si MongoDB est injoignable** : vérifier le réseau, le service mongod, les limites de connexions +6. **Si MongoDB est surchargé** : identifier les opérations concurrentes, envisager un throttle + +### Escalade + +| Durée | Action | +|-------|--------| +| Immédiat (> 0.1%) | **Alerte critique** — Notifier l'équipe Dev immédiatement | +| 0-5 min | Diagnostic rapide : MongoDB up? Index OK? Disk space? | +| 5-15 min | Notifier Dev + Ops, vérifier infrastructure MongoDB | +| > 15 min | Escalade management, envisager rollback vers fichiers | +| Immédiat (> 1.0%) | **Alerte urgente** — Activer `FEATURE_EXCHANGE_PERSISTENCE=false`, diagnostic prioritaire | + +--- + +## Alert 4: TTL Cleanup Lag > 1 Hour + +### Seuil + +| Métrique | Condition | Sévérité | +|----------|-----------|----------| +| `exchange.ttl_cleanup_lag` | > 1 heure | **Warning** | +| `exchange.ttl_cleanup_lag` | > 6 heures | **Critical** | + +### Description + +L'index TTL sur le champ `createdAt` (expire après 86400 secondes = 24h) est géré par un thread interne MongoDB qui s'exécute toutes les 60 secondes. Si le cleanup prend du retard, des documents expirés s'accumulent, augmentant la taille de la collection et dégradant les performances de requête. + +Le "lag" est mesuré comme la différence entre le plus ancien document présent dans la collection et son heure d'expiration théorique (createdAt + 24h). + +### Causes possibles + +- MongoDB TTL monitor surchargé (collection très volumineuse, beaucoup de documents à supprimer) +- Index TTL supprimé ou corrompu +- MongoDB en mode de maintenance (locks, compaction) +- Charge élevée sur le serveur empêchant le thread TTL de s'exécuter +- Bug dans la configuration du TTL (mauvais champ, mauvaise valeur `expireAfterSeconds`) + +### Impact + +- Croissance non contrôlée de la collection `cwmp_exchanges` +- Dégradation progressive des performances de lecture (scans plus larges) +- Consommation disque accrue +- Potentiel dépassement des limites de stockage + +### Détection + +Vérification manuelle du lag : +```bash +# Trouver le document le plus ancien +mongosh --eval " + const oldest = db.cwmp_exchanges.find().sort({createdAt: 1}).limit(1).toArray()[0]; + if (oldest) { + const expectedExpiry = new Date(oldest.createdAt.getTime() + 86400000); + const lag = Date.now() - expectedExpiry.getTime(); + print('Oldest createdAt:', oldest.createdAt); + print('Expected expiry:', expectedExpiry); + print('Lag (ms):', Math.max(0, lag)); + print('Lag (hours):', Math.max(0, lag / 3600000).toFixed(2)); + } else { + print('Collection empty'); + } +" +``` + +Vérification de l'index TTL : +```bash +mongosh --eval " + const indexes = db.cwmp_exchanges.getIndexes(); + const ttlIndex = indexes.find(i => i.expireAfterSeconds !== undefined); + if (ttlIndex) { + print('TTL index found:', JSON.stringify(ttlIndex)); + } else { + print('WARNING: No TTL index found!'); + } +" +``` + +### Procédure de résolution + +1. **Vérifier** que l'index TTL existe et est correctement configuré : + ```bash + mongosh --eval "db.cwmp_exchanges.getIndexes()" + ``` + L'index attendu : `{ "createdAt": 1 }` avec `expireAfterSeconds: 86400` + +2. **Si l'index TTL est manquant** — le recréer : + ```bash + mongosh --eval "db.cwmp_exchanges.createIndex({createdAt: 1}, {expireAfterSeconds: 86400})" + ``` + +3. **Si l'index existe mais le cleanup est en retard** : + - Vérifier la charge MongoDB : `db.serverStatus().metrics.ttl` + - Vérifier si un grand nombre de documents doivent être supprimés d'un coup + - Envisager un nettoyage manuel des plus anciens documents : + ```bash + mongosh --eval " + const cutoff = new Date(Date.now() - 86400000); + const result = db.cwmp_exchanges.deleteMany({createdAt: {$lt: cutoff}}); + print('Deleted:', result.deletedCount); + " + ``` + +4. **Si MongoDB est en maintenance** : attendre la fin de l'opération, surveiller la reprise du TTL + +### Escalade + +| Durée | Action | +|-------|--------| +| 0-1h | Monitoring automatique (TTL thread cycle = 60s, léger retard normal) | +| 1-3h (Warning) | Vérifier l'existence de l'index TTL, vérifier la charge MongoDB | +| 3-6h (Warning) | Notifier l'équipe Dev, envisager un nettoyage manuel | +| > 6h (Critical) | Notifier Dev + DBA, nettoyage manuel obligatoire, diagnostic approfondi | +| > 24h | Escalade management — la collection grandit sans limite | + +--- + +## Résumé des alertes + +| # | Alerte | Seuil Warning | Seuil Critical | Métrique | +|---|--------|---------------|----------------|----------| +| 1 | SSE Active Connections | > 100 | > 200 | `sse.active_connections` | +| 2 | MongoDB Write Latency p95 | > 100ms | > 500ms | `mongodb.write_latency_p95` | +| 3 | Message Loss Rate | > 0.1% | > 1.0% | `exchange.loss_rate` | +| 4 | TTL Cleanup Lag | > 1 heure | > 6 heures | `exchange.ttl_cleanup_lag` | + +--- + +## Configuration des alertes + +### Variables d'environnement + +| Variable | Défaut | Description | +|----------|--------|-------------| +| `ALERT_SSE_CONNECTIONS_WARNING` | `100` | Seuil warning connexions SSE | +| `ALERT_SSE_CONNECTIONS_CRITICAL` | `200` | Seuil critical connexions SSE | +| `ALERT_MONGO_LATENCY_P95_WARNING_MS` | `100` | Seuil warning latence MongoDB p95 (ms) | +| `ALERT_MONGO_LATENCY_P95_CRITICAL_MS` | `500` | Seuil critical latence MongoDB p95 (ms) | +| `ALERT_LOSS_RATE_CRITICAL_PCT` | `0.1` | Seuil critical taux de perte (%) | +| `ALERT_TTL_LAG_WARNING_HOURS` | `1` | Seuil warning lag TTL (heures) | +| `ALERT_TTL_LAG_CRITICAL_HOURS` | `6` | Seuil critical lag TTL (heures) | + +### Intégration avec les systèmes d'alerte + +Les alertes sont déclenchées via les logs structurés JSON, compatibles avec : + +- **ELK Stack** : index pattern `adam-acs-*`, filtres sur `level: "error"` ou `level: "warn"` + tags spécifiques +- **Datadog** : log-based monitors avec conditions sur les champs JSON +- **Prometheus/Grafana** : si le endpoint `/metrics` est exposé, utiliser les alerting rules Prometheus + +Exemple de règle Prometheus : +```yaml +groups: + - name: tr069-exchange-viewer + rules: + - alert: HighSSEConnections + expr: adam_acs_sse_active_connections > 100 + for: 5m + labels: + severity: warning + annotations: + summary: "High SSE connection count" + description: "{{ $value }} SSE connections active (threshold: 100)" + + - alert: MongoWriteLatencyHigh + expr: histogram_quantile(0.95, adam_acs_mongodb_write_duration_seconds_bucket{collection="cwmp_exchanges"}) > 0.1 + for: 5m + labels: + severity: warning + annotations: + summary: "MongoDB write latency p95 > 100ms" + description: "p95 latency: {{ $value }}s for cwmp_exchanges writes" + + - alert: ExchangeMessageLoss + expr: rate(adam_acs_exchange_messages_lost_total[5m]) / rate(adam_acs_exchange_messages_captured_total[5m]) > 0.001 + for: 1m + labels: + severity: critical + annotations: + summary: "Message loss rate exceeds 0.1%" + description: "{{ $value | humanizePercentage }} messages lost in last 5 minutes" + + - alert: TTLCleanupLag + expr: adam_acs_exchange_ttl_lag_seconds > 3600 + for: 10m + labels: + severity: warning + annotations: + summary: "TTL cleanup lag exceeds 1 hour" + description: "Oldest expired document is {{ $value | humanizeDuration }} past its TTL" +``` + +--- + +## Contacts d'escalade + +| Niveau | Contact | Canal | +|--------|---------|-------| +| L1 — Warning | Équipe Dev adam-acs | Slack #adam-acs-alerts | +| L2 — Critical | Lead Dev + Ops | Slack #adam-acs-alerts + mention @oncall | +| L3 — Urgent/Prolongé | Management technique | Escalade téléphonique | +| DBA (MongoDB) | Équipe DBA | Slack #dba-support | diff --git a/docs/tr06_sequence/DESIGN.md b/docs/tr06_sequence/DESIGN.md new file mode 100644 index 0000000..7a20289 --- /dev/null +++ b/docs/tr06_sequence/DESIGN.md @@ -0,0 +1,180 @@ +--- +name: Precision Protocol System +colors: + surface: '#f8f9ff' + surface-dim: '#cbdbf5' + surface-bright: '#f8f9ff' + surface-container-lowest: '#ffffff' + surface-container-low: '#eff4ff' + surface-container: '#e5eeff' + surface-container-high: '#dce9ff' + surface-container-highest: '#d3e4fe' + on-surface: '#0b1c30' + on-surface-variant: '#45464d' + inverse-surface: '#213145' + inverse-on-surface: '#eaf1ff' + outline: '#76777d' + outline-variant: '#c6c6cd' + surface-tint: '#565e74' + primary: '#000000' + on-primary: '#ffffff' + primary-container: '#131b2e' + on-primary-container: '#7c839b' + inverse-primary: '#bec6e0' + secondary: '#006a61' + on-secondary: '#ffffff' + secondary-container: '#86f2e4' + on-secondary-container: '#006f66' + tertiary: '#000000' + on-tertiary: '#ffffff' + tertiary-container: '#0f0069' + on-tertiary-container: '#7671ff' + error: '#ba1a1a' + on-error: '#ffffff' + error-container: '#ffdad6' + on-error-container: '#93000a' + primary-fixed: '#dae2fd' + primary-fixed-dim: '#bec6e0' + on-primary-fixed: '#131b2e' + on-primary-fixed-variant: '#3f465c' + secondary-fixed: '#89f5e7' + secondary-fixed-dim: '#6bd8cb' + on-secondary-fixed: '#00201d' + on-secondary-fixed-variant: '#005049' + tertiary-fixed: '#e2dfff' + tertiary-fixed-dim: '#c3c0ff' + on-tertiary-fixed: '#0f0069' + on-tertiary-fixed-variant: '#3323cc' + background: '#f8f9ff' + on-background: '#0b1c30' + surface-variant: '#d3e4fe' +typography: + headline-lg: + fontFamily: Inter + fontSize: 30px + fontWeight: '600' + lineHeight: 38px + letterSpacing: -0.02em + headline-md: + fontFamily: Inter + fontSize: 24px + fontWeight: '600' + lineHeight: 32px + letterSpacing: -0.01em + headline-sm: + fontFamily: Inter + fontSize: 18px + fontWeight: '600' + lineHeight: 26px + body-lg: + fontFamily: Inter + fontSize: 16px + fontWeight: '400' + lineHeight: 24px + body-md: + fontFamily: Inter + fontSize: 14px + fontWeight: '400' + lineHeight: 20px + body-sm: + fontFamily: Inter + fontSize: 13px + fontWeight: '400' + lineHeight: 18px + label-md: + fontFamily: Inter + fontSize: 12px + fontWeight: '500' + lineHeight: 16px + letterSpacing: 0.05em + mono-md: + fontFamily: JetBrains Mono + fontSize: 13px + fontWeight: '400' + lineHeight: 20px + mono-sm: + fontFamily: JetBrains Mono + fontSize: 11px + fontWeight: '400' + lineHeight: 16px +rounded: + sm: 0.125rem + DEFAULT: 0.25rem + md: 0.375rem + lg: 0.5rem + xl: 0.75rem + full: 9999px +spacing: + unit: 4px + container-padding: 24px + gutter: 16px + sidebar-width: 260px + stack-sm: 8px + stack-md: 16px + stack-lg: 32px +--- + +## Brand & Style + +This design system is engineered for high-performance network analysis and protocol monitoring. The brand personality is rooted in **precision, neutrality, and clarity**. It avoids decorative flourishes to prioritize the rapid interpretation of complex data structures. + +The visual style is a blend of **Minimalism** and **Modern Corporate** aesthetics. It utilizes a spacious layout to reduce the cognitive load inherent in dense data environments. The interface conveys reliability through a systematic application of subtle borders and high-contrast typography, ensuring that critical network events are immediately visible. + +## Colors + +The palette is anchored in **Slate and Zinc** neutrals to provide a professional, calm environment for long-term monitoring. + +* **Primary (Slate 950):** Used for core navigation, high-level headers, and text to ensure maximum contrast. +* **Neutral (Slate/Zinc):** Used for structural elements, secondary text, and inactive states. +* **Flow Accents:** Semantic colors are reserved for directional data. **Teal (ACS -> CPE)** and **Indigo (CPE -> ACS)** allow engineers to visually parse the direction of TR-069 traffic at a glance without reading text labels. +* **Success/Error:** Traditional Green and Red are used sparingly for status indicators and connection states. + +## Typography + +This design system utilizes a dual-font strategy. **Inter** is the workhorse for the UI, chosen for its exceptional legibility in small sizes and high-density layouts. **JetBrains Mono** is utilized exclusively for technical data, including XML, JSON payloads, and packet hex dumps. + +Headlines use tighter letter spacing for a more authoritative look, while labels utilize uppercase styling and increased tracking for clear categorization in sidebars and table headers. Monospaced text is rendered with a slightly smaller font size than body text to accommodate long lines of code without excessive wrapping. + +## Layout & Spacing + +The layout follows a **Hybrid Grid** model. Navigation and property inspectors use fixed-width sidebars, while the main content area—typically containing packet logs or traffic graphs—uses a **Fluid Grid** to maximize horizontal real estate. + +### Breakpoints +* **Desktop (1280px+):** 12-column fluid grid, 24px margins. +* **Tablet (768px - 1279px):** 8-column fluid grid, 16px margins. Sidebar collapses to icons. +* **Mobile (<767px):** 4-column fluid grid, 12px margins. Horizontal scrolling enabled for data tables. + +Spacing is based on a **4px base unit**. Component internals use 8px or 12px increments to maintain a compact, "instrumental" feel without appearing cluttered. + +## Elevation & Depth + +To maintain a technical and minimalist aesthetic, this design system avoids heavy shadows. Instead, it relies on **Tonal Layers** and **Low-Contrast Outlines**. + +* **Background (Level 0):** Slate-50. The base canvas. +* **Surface (Level 1):** White. Primary cards and panels. +* **Active/Overlay (Level 2):** White with a subtle 1px border in Slate-200 and a very soft, high-diffusion shadow (4px blur, 2% opacity) to indicate temporary elements like tooltips or dropdowns. + +Visual separation between adjacent panels is achieved via 1px solid borders in Slate-200 rather than elevation, ensuring the UI remains crisp and "flat." + +## Shapes + +The shape language is **Soft (Level 1)**. A 4px radius is applied to buttons, input fields, and panels. This subtle rounding provides a modern touch without sacrificing the professional, rigid structure required for a technical tool. + +Status indicators (pills) use a slightly larger 12px radius to distinguish them from interactive buttons, while data visualization bars use sharp 2px corners for maximum precision. + +## Components + +### Buttons +Primary buttons use the Slate-950 background with white text. Ghost buttons (transparent background with Slate-200 borders) are preferred for secondary actions to keep the focus on the data. + +### Packet Log Entries +Logs must use a zebra-stripe pattern (White / Slate-50) for horizontal tracking. The "Flow Direction" is indicated by a 4px vertical accent bar on the far left of the row (Teal or Indigo). + +### Input Fields +Fields use a 1px Slate-200 border. On focus, the border transitions to Slate-900. Labels are always positioned above the input in the `label-md` style for clarity. + +### Data Displays (Code Blocks) +XML/JSON payloads are wrapped in containers with a Slate-50 background and a 1px border. They use `mono-md` typography with syntax highlighting that mirrors the flow colors (Teal for tags, Indigo for values). + +### Chips & Badges +Used for protocol methods (e.g., `Inform`, `GetParameterValues`). These are small, low-contrast components (Slate-100 background) to avoid competing with the flow direction accents. \ No newline at end of file diff --git a/docs/tr06_sequence/code.html b/docs/tr06_sequence/code.html new file mode 100644 index 0000000..42814fd --- /dev/null +++ b/docs/tr06_sequence/code.html @@ -0,0 +1,367 @@ + + + + + +TR-069 Protocol Analyzer + + + + + + + + + +
+
+TR-069 Protocol Analyzer + +
+
+
+filter_list +
+settings +help +
+
+
+ + + +
+ +
+
+
+Device Serial +TR069-NODE-550291-B +
+
+
+IP Address +192.168.100.42 +
+
+
+Connection Status +
+ +CWMP Session Established +
+
+
+
+Session ID: 88241 + +
+
+ +
+
+ +
+
CPE
+
ACS
+
+ +
+
+ +
+ +
+
+
+
+
+
+Inform +10:05:22.450 +
+
+ +
+ +
+
+
+
+
+
+InformResponse +10:05:22.612 +
+
+ +
+ +
+ +
+
+
+
+
+
+GetParameterValues +10:05:23.002 +
+
+ +
+ +
+
+
+
+
+
+GetParameterValuesResponse +10:05:23.189 +
+
+ +
+ +
+
+
+
+
+
+SetParameterValues +10:05:23.441 +
+
+ +
+
+
+ +
+
+
+description +Message Detail +
+
+ + +
+
+
+
+CWMP:GetParameterValues +ID: 1024_A +
+ +
+
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
+  <soap:Header>
+    <cwmp:ID soap:mustUnderstand="1">1024_A</cwmp:ID>
+  </soap:Header>
+  <soap:Body>
+    <cwmp:GetParameterValues>
+      <ParameterNames soap:arrayType="xsd:string[3]">
+        <string>InternetGatewayDevice.DeviceInfo.HardwareVersion</string>
+        <string>InternetGatewayDevice.DeviceInfo.SoftwareVersion</string>
+        <string>InternetGatewayDevice.ManagementServer.ConnectionRequestURL</string>
+      </ParameterNames>
+    </cwmp:GetParameterValues>
+  </soap:Body>
+</soap:Envelope>
+
+
+

Analysis Summary

+
+

ACS is requesting device hardware and software versioning information to determine compatible firmware upgrade paths.

+
+
+
+MTU Size +1500 Bytes +
+
+Latency +42ms +
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/docs/tr06_sequence/screen.png b/docs/tr06_sequence/screen.png new file mode 100644 index 0000000000000000000000000000000000000000..360712c4c47e7407698fca3cb8220cf108fcd91d GIT binary patch literal 261228 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

l2x6v?;$L@M+&)(? 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 z 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

*hHlm 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�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

z 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 YZiL8)*!<|9|NZreil%b$!)LGm6TcxR-v9sr literal 0 HcmV?d00001 diff --git a/docs/troubleshooting/tr069-exchange-viewer.md b/docs/troubleshooting/tr069-exchange-viewer.md new file mode 100644 index 0000000..45ffa5d --- /dev/null +++ b/docs/troubleshooting/tr069-exchange-viewer.md @@ -0,0 +1,486 @@ +# Troubleshooting Guide: TR-069 Exchange Viewer + +Ce guide documente les problèmes courants du système de visualisation des échanges TR-069/CWMP, leurs causes et solutions. + +## Table des matières + +1. [Déconnexions SSE](#1-déconnexions-sse) +2. [Timeouts MongoDB et récupération](#2-timeouts-mongodb-et-récupération) +3. [Diagnostic de perte de messages](#3-diagnostic-de-perte-de-messages) +4. [Vérification des index et du TTL](#4-vérification-des-index-et-du-ttl) +5. [Problèmes frontend (SequenceViewer)](#5-problèmes-frontend-sequenceviewer) +6. [Feature flags et rollback](#6-feature-flags-et-rollback) + +--- + +## 1. Déconnexions SSE + +### 1.1 Causes courantes + +| Cause | Symptôme | Fréquence | +|-------|----------|-----------| +| **Timeout d'inactivité (5 min)** | Déconnexion après 5 minutes sans nouvel échange | Normal — par design | +| **Timeout absolu (2 h)** | Déconnexion après 2 heures de connexion continue | Normal — par design | +| **Proxy/reverse-proxy buffering** | Messages SSE arrivent en burst au lieu de temps réel | Courant avec nginx | +| **Perte réseau** | Indicateur "disconnected" dans le viewer | Variable | +| **SSE désactivé (feature flag)** | Erreur 503 à la connexion SSE | Intentionnel | +| **Device non tracé** | Erreur 404 à la connexion SSE | Erreur utilisateur | + +### 1.2 Solutions + +#### Timeout d'inactivité / absolu + +Le système est conçu pour fermer les connexions inactives. Le client se reconnecte automatiquement via le mécanisme `Last-Event-ID` — aucun message n'est perdu. + +Pour ajuster les timeouts : + +```bash +# Augmenter le timeout d'inactivité (défaut: 300000ms = 5 min) +EXCHANGE_STORE_SSE_INACTIVITY_TIMEOUT_MS=600000 + +# Augmenter le timeout absolu (défaut: 7200000ms = 2h) +EXCHANGE_STORE_SSE_ABSOLUTE_TIMEOUT_MS=14400000 +``` + +#### Proxy/reverse-proxy buffering + +Si un reverse-proxy (nginx, HAProxy) est en place, il peut bufferiser les réponses SSE. Le header `X-Accel-Buffering: no` est déjà envoyé par le serveur, mais vérifiez aussi la configuration nginx : + +```nginx +location /api/v1/tr069-trace/ { + proxy_buffering off; + proxy_cache off; + proxy_set_header Connection ''; + proxy_http_version 1.1; + chunked_transfer_encoding off; +} +``` + +#### Client qui ne se reconnecte pas + +Le composable `useExchangeStream` utilise l'`EventSource` natif avec retry automatique (3 secondes par défaut). Si la reconnexion ne fonctionne pas : + +1. Vérifiez que le device est toujours en trace active +2. Vérifiez les logs serveur pour des erreurs 404 ou 503 +3. Vérifiez la valeur `retry` dans la configuration SSE : + +```bash +# Intervalle de reconnexion envoyé au client (défaut: 3000ms) +EXCHANGE_STORE_SSE_RETRY_INTERVAL_MS=3000 +``` + +### 1.3 Diagnostic dans les logs + +Cherchez les entrées `[SSE]` dans les logs applicatifs : + +```bash +docker compose logs app | grep '\[SSE\]' +``` + +Messages attendus : +- `[SSE] Connection closed { deviceId: "...", reason: "inactivity" }` — timeout normal +- `[SSE] Connection closed { deviceId: "...", reason: "absolute" }` — timeout absolu normal +- `[SSE] Connection closed { deviceId: "...", reason: "client_disconnect" }` — client a fermé +- `[SSE] Error fetching missed exchanges for reconnection` — problème MongoDB lors du rattrapage + +--- + +## 2. Timeouts MongoDB et récupération + +### 2.1 Scénarios de timeout + +| Scénario | Impact | Comportement | +|----------|--------|--------------| +| **Write timeout (persistance)** | Message potentiellement perdu | Retry (critique) ou discard (non-critique) | +| **Read timeout (API >5s)** | Résultats partiels retournés | `isTruncated: true` dans la réponse | +| **Connection lost** | Toutes les opérations échouent | Les sessions CWMP continuent sans log | + +### 2.2 Write timeout — Retry automatique + +Le `ExchangeStoreService` gère les échecs d'écriture selon la criticité du message : + +**Messages critiques** (Inform, GetParameterValues, SetParameterValuesResponse, Fault, etc.) : +- 3 tentatives avec backoff exponentiel : 1s, 2s, 4s +- Si toutes échouent : log error + discard (session CWMP non impactée) + +**Messages non-critiques** (Empty, Unknown) : +- Aucun retry — discard immédiat + log warning + +Vérifier les échecs de retry : + +```bash +docker compose logs app | grep '\[ExchangeStore\] All retries exhausted' +docker compose logs app | grep '\[ExchangeStore\] Critical message persistence failed' +``` + +Pour ajuster la politique de retry : + +```bash +# Nombre max de tentatives (défaut: 3) +EXCHANGE_STORE_RETRY_MAX_ATTEMPTS=3 + +# Délai de base en ms pour le backoff (défaut: 1000) +# Résultat : 1000ms, 2000ms, 4000ms +EXCHANGE_STORE_RETRY_BASE_DELAY_MS=1000 +``` + +### 2.3 Read timeout — Résultats partiels + +Si une requête API dépasse 5 secondes, le contrôleur retourne les résultats partiels avec un flag : + +```json +{ + "exchanges": [...], + "metadata": { + "isTruncated": true, + "total": 4500 + } +} +``` + +**Causes courantes :** +- Index manquant sur `(deviceId, timestamp)` — voir section 4 +- Trop de documents pour le device (proche du cap 5000) +- Charge MongoDB élevée (vérifier les métriques) + +Pour ajuster le timeout de lecture : + +```bash +# Timeout en ms (défaut: 5000) +EXCHANGE_STORE_READ_TIMEOUT_MS=5000 +``` + +### 2.4 Vérifier la connectivité MongoDB + +```bash +# Se connecter au shell MongoDB +docker compose exec mongodb mongosh + +# Vérifier le statut +db.adminCommand({ serverStatus: 1 }).connections + +# Vérifier la latence +db.adminCommand({ ping: 1 }) +``` + +--- + +## 3. Diagnostic de perte de messages + +### 3.1 Arbre de décision + +``` +Message manquant dans le viewer ? +│ +├─ Le device est-il en trace ? +│ └─ Non → Activer la trace d'abord. Les messages ne sont PAS persistés hors trace. +│ +├─ Le feature flag FEATURE_EXCHANGE_PERSISTENCE est-il à true ? +│ └─ Non → Activer la persistance : FEATURE_EXCHANGE_PERSISTENCE=true +│ +├─ Le message apparaît-il dans MongoDB mais pas dans le viewer ? +│ └─ Oui → Problème SSE (voir section 1) ou problème frontend (voir section 5) +│ +├─ Le message n'apparaît PAS dans MongoDB ? +│ ├─ Vérifier les logs de retry : grep "All retries exhausted" +│ ├─ Vérifier le cap (5000 messages) : les anciens messages sont supprimés +│ └─ Vérifier le TTL (24h) : les messages >24h sont auto-supprimés +│ +└─ Message tronqué (XML incomplet) ? + └─ Le rawXml dépassait 2MB → grep "rawXml exceeds 2MB" dans les logs +``` + +### 3.2 Vérifier les messages en base + +```bash +# Se connecter au shell MongoDB +docker compose exec mongodb mongosh + +# Compter les messages pour un device +use adam_acs +db.cwmp_exchanges.countDocuments({ deviceId: "" }) + +# Voir les derniers messages +db.cwmp_exchanges.find({ deviceId: "" }).sort({ timestamp: -1 }).limit(5).pretty() + +# Voir la séquence max (dernier message) +db.cwmp_exchange_counters.findOne({ _id: "" }) +``` + +### 3.3 Vérifier les logs de persistance + +```bash +# Chercher les erreurs de persistance +docker compose logs app | grep '\[ExchangeStore\]' + +# Messages spécifiques +docker compose logs app | grep 'Failed to persist exchange' +docker compose logs app | grep 'Non-critical message persistence failed' +docker compose logs app | grep 'All retries exhausted' +docker compose logs app | grep 'Message cap enforced' +docker compose logs app | grep 'rawXml exceeds 2MB' +``` + +### 3.4 Message supprimé par le cap + +Le système maintient un maximum de 5000 messages par device. Quand ce cap est dépassé, les messages les plus anciens sont supprimés. + +```bash +# Vérifier si le cap a été atteint +docker compose logs app | grep 'Message cap enforced' +``` + +Pour ajuster le cap : + +```bash +# Nombre max de messages par device (défaut: 5000) +EXCHANGE_STORE_MAX_MESSAGES=5000 +``` + +### 3.5 Message supprimé par le TTL + +MongoDB supprime automatiquement les documents dont `createdAt` dépasse 24 heures (via l'index TTL). + +```bash +# Vérifier les documents les plus anciens +docker compose exec mongodb mongosh --eval " + use adam_acs; + db.cwmp_exchanges.find({}, { createdAt: 1, deviceId: 1 }).sort({ createdAt: 1 }).limit(3).pretty() +" +``` + +--- + +## 4. Vérification des index et du TTL + +### 4.1 Lister les index existants + +```bash +docker compose exec mongodb mongosh --eval " + use adam_acs; + db.cwmp_exchanges.getIndexes().forEach(idx => printjson(idx)) +" +``` + +**Index attendus :** + +| Nom | Champs | Options | +|-----|--------|---------| +| `_id_` | `{ _id: 1 }` | — | +| `idx_deviceId_timestamp` | `{ deviceId: 1, timestamp: 1 }` | — | +| `idx_deviceId_sequenceNumber` | `{ deviceId: 1, sequenceNumber: 1 }` | `unique: true` | +| `idx_createdAt_ttl` | `{ createdAt: 1 }` | `expireAfterSeconds: 86400` | + +### 4.2 Vérifier l'index TTL + +```bash +docker compose exec mongodb mongosh --eval " + use adam_acs; + const indexes = db.cwmp_exchanges.getIndexes(); + const ttlIdx = indexes.find(i => i.name === 'idx_createdAt_ttl'); + if (ttlIdx) { + print('TTL index OK — expireAfterSeconds:', ttlIdx.expireAfterSeconds); + } else { + print('ERREUR: TTL index manquant !'); + } +" +``` + +### 4.3 Recréer les index manuellement (si manquants) + +Si des index sont manquants après un incident : + +```bash +docker compose exec mongodb mongosh --eval " + use adam_acs; + + // Index composite (deviceId + timestamp) + db.cwmp_exchanges.createIndex( + { deviceId: 1, timestamp: 1 }, + { name: 'idx_deviceId_timestamp' } + ); + + // Index unique (deviceId + sequenceNumber) + db.cwmp_exchanges.createIndex( + { deviceId: 1, sequenceNumber: 1 }, + { unique: true, name: 'idx_deviceId_sequenceNumber' } + ); + + // Index TTL (24h) + db.cwmp_exchanges.createIndex( + { createdAt: 1 }, + { expireAfterSeconds: 86400, name: 'idx_createdAt_ttl' } + ); + + print('Tous les index recréés.'); +" +``` + +### 4.4 Vérifier le statut du TTL monitor + +Le TTL monitor de MongoDB tourne par défaut toutes les 60 secondes. Pour vérifier qu'il fonctionne : + +```bash +docker compose exec mongodb mongosh --eval " + const status = db.adminCommand({ serverStatus: 1 }); + print('TTL deletedDocuments:', status.metrics.ttl.deletedDocuments); + print('TTL passes:', status.metrics.ttl.passes); +" +``` + +Si `passes` n'augmente pas, le TTL monitor pourrait être bloqué (rare, nécessite un restart MongoDB). + +### 4.5 Modifier le TTL + +Pour changer la durée du TTL sans recréer l'index : + +```bash +docker compose exec mongodb mongosh --eval " + use adam_acs; + db.runCommand({ + collMod: 'cwmp_exchanges', + index: { + name: 'idx_createdAt_ttl', + expireAfterSeconds: 172800 // 48h + } + }); +" +``` + +N'oubliez pas de mettre à jour la variable d'environnement pour cohérence : + +```bash +EXCHANGE_STORE_TTL_SECONDS=172800 +``` + +### 4.6 Vérifier la collection counters + +La collection `cwmp_exchange_counters` stocke les séquences atomiques par device : + +```bash +docker compose exec mongodb mongosh --eval " + use adam_acs; + db.cwmp_exchange_counters.find().forEach(doc => { + print(doc._id, '→ seq:', doc.seq); + }) +" +``` + +Si un compteur est incohérent avec les données réelles : + +```bash +# Recalculer le max sequenceNumber pour un device +docker compose exec mongodb mongosh --eval " + use adam_acs; + const deviceId = ''; + const maxSeq = db.cwmp_exchanges.find({ deviceId }).sort({ sequenceNumber: -1 }).limit(1).toArray(); + if (maxSeq.length > 0) { + db.cwmp_exchange_counters.updateOne( + { _id: deviceId }, + { \$set: { seq: maxSeq[0].sequenceNumber } } + ); + print('Counter reset to', maxSeq[0].sequenceNumber); + } +" +``` + +--- + +## 5. Problèmes frontend (SequenceViewer) + +### 5.1 Le viewer ne se connecte pas au SSE + +**Causes possibles :** +1. Le device n'est pas en trace → activer la trace d'abord +2. Le feature flag `FEATURE_SSE_ENABLED` est à `false` → erreur 503 +3. Problème CORS si accès depuis un domaine différent + +**Diagnostic :** Ouvrir la console navigateur (F12) et chercher les erreurs EventSource. + +### 5.2 Les messages ne s'affichent pas en temps réel + +Si les messages arrivent dans MongoDB mais pas dans le viewer : +1. Vérifier l'indicateur de connexion : doit être "connected" (vert) +2. Si "disconnected" (rouge) : voir section 1 +3. Si "paused" (jaune) : l'auto-scroll est désactivé car l'utilisateur a scrollé vers le haut — cliquer "Resume auto-scroll" + +### 5.3 Performance lente avec beaucoup de messages + +Le viewer utilise du virtual scrolling pour >200 messages. Si la performance se dégrade : +- Le cap client est fixé à 1000 messages en mémoire (les plus anciens sont purgés côté client) +- Si le nombre total dépasse 5000 en base, les plus anciens sont purgés côté serveur +- Utiliser "Clear exchanges" pour repartir d'un état vide + +### 5.4 L'inspecteur XML affiche du contenu brut (non formaté) + +Si le XML n'est pas mis en forme : +- Le XML est peut-être malformé → l'inspecteur affiche le contenu brut en fallback +- Vérifier si le message a un `rawXml` vide (cas "Empty") + +--- + +## 6. Feature flags et rollback + +### 6.1 Flags disponibles + +| Variable d'environnement | Défaut | Effet si `false`/`file` | +|--------------------------|--------|-------------------------| +| `FEATURE_EXCHANGE_PERSISTENCE` | `true` | Les échanges ne sont pas écrits en MongoDB | +| `FEATURE_SSE_ENABLED` | `true` | L'endpoint SSE retourne 503, le client utilise le REST polling (5s) | +| `FEATURE_EXCHANGE_API_SOURCE` | `mongodb` | Si `file`, l'API lit depuis l'ancien système de fichiers | + +### 6.2 Procédure de rollback rapide + +En cas de problème critique : + +```bash +# 1. Désactiver la persistance (les sessions CWMP continuent normalement) +FEATURE_EXCHANGE_PERSISTENCE=false + +# 2. Désactiver le SSE (le frontend utilise le polling REST) +FEATURE_SSE_ENABLED=false + +# 3. Revenir à la source fichier pour l'API +FEATURE_EXCHANGE_API_SOURCE=file +``` + +Redémarrer l'application après modification : + +```bash +docker compose restart app +``` + +### 6.3 Vérifier l'état actuel des flags + +Les valeurs actives sont loguées au démarrage de l'application. Vous pouvez aussi interroger le health check : + +```bash +curl -s http://localhost:8080/api/v1/health/tr069-exchanges | python3 -m json.tool +``` + +--- + +## Commandes utiles — Résumé rapide + +```bash +# Status général +docker compose logs app --tail 20 | grep -E '\[ExchangeStore\]|\[SSE\]' + +# Compter les échanges d'un device +docker compose exec mongodb mongosh --eval "use adam_acs; db.cwmp_exchanges.countDocuments({deviceId:''})" + +# Vérifier les index +docker compose exec mongodb mongosh --eval "use adam_acs; db.cwmp_exchanges.getIndexes().forEach(i=>printjson(i))" + +# Vérifier le TTL monitor +docker compose exec mongodb mongosh --eval "printjson(db.adminCommand({serverStatus:1}).metrics.ttl)" + +# Tester la connexion SSE manuellement +curl -N -H "Accept: text/event-stream" http://localhost:8080/api/v1/tr069-trace//exchanges/stream + +# Tester la reconnexion avec Last-Event-ID +curl -N -H "Accept: text/event-stream" -H "Last-Event-ID: 42" http://localhost:8080/api/v1/tr069-trace//exchanges/stream + +# Purger les échanges d'un device +curl -X DELETE http://localhost:8080/api/v1/tr069-trace//exchanges +``` diff --git a/inertia/components/InspectorPanel.vue b/inertia/components/InspectorPanel.vue new file mode 100644 index 0000000..6539246 --- /dev/null +++ b/inertia/components/InspectorPanel.vue @@ -0,0 +1,348 @@ + + + + + diff --git a/inertia/components/SequenceViewer.vue b/inertia/components/SequenceViewer.vue new file mode 100644 index 0000000..e27f3f5 --- /dev/null +++ b/inertia/components/SequenceViewer.vue @@ -0,0 +1,679 @@ + + + + + diff --git a/inertia/composables/useExchangeStream.ts b/inertia/composables/useExchangeStream.ts new file mode 100644 index 0000000..86cb843 --- /dev/null +++ b/inertia/composables/useExchangeStream.ts @@ -0,0 +1,290 @@ +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 + /** Current connection state */ + connectionState: Ref + /** Whether auto-scroll is enabled (paused when user scrolls up) */ + isAutoScrollEnabled: Ref + /** 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 +} + +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([]) + const connectionState = ref('disconnected') + const isAutoScrollEnabled = ref(true) + + let eventSource: EventSource | null = null + let lastSequenceNumber: number | null = null + let pollingTimer: ReturnType | 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 { + 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, + } +} diff --git a/inertia/helpers/sequence_viewer_helpers.ts b/inertia/helpers/sequence_viewer_helpers.ts new file mode 100644 index 0000000..d272925 --- /dev/null +++ b/inertia/helpers/sequence_viewer_helpers.ts @@ -0,0 +1,51 @@ +/** + * Formatting helpers for the SequenceViewer component. + * Extracted for testability and reuse. + */ + +/** + * Determines the CSS arrow direction class based on message direction. + * "CPE→ACS" messages point left-to-right (arrow-right). + * "ACS→CPE" messages point right-to-left (arrow-left). + */ +export function getArrowClass(direction: 'CPE→ACS' | 'ACS→CPE'): 'arrow-right' | 'arrow-left' { + return direction === 'CPE→ACS' ? 'arrow-right' : 'arrow-left' +} + +/** + * Formats an ISO 8601 timestamp into HH:mm:ss.SSS display format. + * Uses UTC time to ensure consistent formatting regardless of timezone. + */ +export function formatTimestamp(isoTimestamp: string): string { + const date = new Date(isoTimestamp) + const hours = String(date.getUTCHours()).padStart(2, '0') + const minutes = String(date.getUTCMinutes()).padStart(2, '0') + const seconds = String(date.getUTCSeconds()).padStart(2, '0') + const millis = String(date.getUTCMilliseconds()).padStart(3, '0') + return `${hours}:${minutes}:${seconds}.${millis}` +} + +/** + * Truncates a CWMP method name to 40 characters if it exceeds that length. + * Appends an ellipsis character (…) when truncated. + */ +export function truncateMethod(method: string): string { + if (method.length > 40) { + return method.substring(0, 40) + '…' + } + return method +} + +/** + * Returns true if the method name exceeds 40 characters and needs truncation. + */ +export function needsTruncation(method: string): boolean { + return method.length > 40 +} + +/** + * Formats a trace start time ISO string into a localized display string. + */ +export function formatTraceStartTime(iso: string): string { + return new Date(iso).toLocaleString() +} diff --git a/inertia/pages/DeviceDetail.vue b/inertia/pages/DeviceDetail.vue index b16b212..051df23 100644 --- a/inertia/pages/DeviceDetail.vue +++ b/inertia/pages/DeviceDetail.vue @@ -3,6 +3,9 @@ import { ref } from 'vue' import { router, Link } from '@inertiajs/vue3' import { useI18n } from 'vue-i18n' import TitleBar from '../components/TitleBar.vue' +import SequenceViewer from '../components/SequenceViewer.vue' +import InspectorPanel from '../components/InspectorPanel.vue' +import type { CwmpExchange } from '../composables/useExchangeStream' const { t } = useI18n() @@ -16,6 +19,8 @@ const isLoading = ref(false) const showDeleteConfirm = ref(false) const isTraced = ref(false) const tracingAction = ref(false) +const traceStartedAt = ref(null) +const selectedMessage = ref(null) // Check if device is already traced async function checkTraceStatus() { @@ -24,7 +29,11 @@ async function checkTraceStatus() { if (response.ok) { const data = await response.json() const entries = data.entries || [] - isTraced.value = entries.some((e: any) => e.deviceId === device.identifier) + const entry = entries.find((e: any) => e.deviceId === device.identifier) + isTraced.value = !!entry + if (entry) { + traceStartedAt.value = entry.startedAt || new Date().toISOString() + } } } catch { // Silently ignore @@ -40,6 +49,8 @@ async function toggleTrace() { }) if (response.ok || response.status === 204) { isTraced.value = false + traceStartedAt.value = null + selectedMessage.value = null } } else { const response = await fetch('/api/v1/tr069-trace', { @@ -48,7 +59,9 @@ async function toggleTrace() { body: JSON.stringify({ deviceId: device.identifier }), }) if (response.ok) { + const data = await response.json() isTraced.value = true + traceStartedAt.value = data.entry?.startedAt || new Date().toISOString() } } } catch { @@ -60,6 +73,10 @@ async function toggleTrace() { checkTraceStatus() +function onMessageSelect(message: CwmpExchange | null) { + selectedMessage.value = message +} + function deleteDevice() { router.delete(`/devices/${encodeURIComponent(device.identifier)}`, { onSuccess: () => router.visit('/devices'), @@ -215,6 +232,11 @@ async function resolveAllProviders() { Actions +