Files
adam-acs/app/models/exchange.ts
T
Kevin L'Hereec 0c83a4ccb2 feat: TR-069 Exchange Viewer - structured MongoDB storage with SSE streaming and sequence diagram UI
- MongoDB storage: cwmp_exchanges collection with indexes (deviceId+timestamp, deviceId+sequenceNumber unique, TTL 24h)
- ExchangeStoreService: CWMP method extraction, per-device queue for ordering, retry logic, message cap (5000/device)
- Exchange API: GET/DELETE/SSE stream endpoints with Last-Event-ID reconnection
- CwmpLoggerMiddleware: dual-write (MongoDB + legacy files) with fire-and-forget
- Frontend: SequenceViewer (CPE↔ACS lifelines, arrows, auto-scroll, virtual scroll) + InspectorPanel (formatted XML, copy, syntax highlighting)
- Tr069Trace page: integrated new viewer with col-6/col-6 layout
- DeviceDetail page: collapsible exchange section when device is traced
- 14 property-based tests, 40+ unit tests, 5 integration tests
- Health check includes index verification
- Config: feature flags, SSE timeouts, retry policy
2026-06-03 15:52:44 +02:00

203 lines
6.0 KiB
TypeScript

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<CwmpExchange[]> {
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<CwmpExchange[]>
}
/**
* 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<CwmpExchange[]> {
const collection = this.getCollection()
return collection
.find({ deviceId, sequenceNumber: { $gt: seqNumber } })
.sort({ sequenceNumber: 1 })
.toArray() as unknown as Promise<CwmpExchange[]>
}
/**
* Delete all exchanges for a device.
* Returns the number of deleted documents.
*/
static async deleteByDevice(deviceId: string): Promise<number> {
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<number> {
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<number> {
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<void> {
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<boolean> {
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<ExchangeMetadata> {
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<void> {
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 }
)
}
}