- 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
203 lines
6.0 KiB
TypeScript
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 }
|
|
)
|
|
}
|
|
}
|