Files
adam-acs/app/services/tr069_trace_service.ts
T
2026-06-01 15:49:09 +02:00

140 lines
3.7 KiB
TypeScript

import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
const APP_ROOT = process.cwd()
const CONFIG_PATH = join(APP_ROOT, 'data', 'tr069-trace.json')
const LOG_DIR = join(APP_ROOT, 'logs', 'tr069')
const MAX_FILE_BYTES = 10 * 1024 * 1024
const MAX_ROTATED_FILES = 5
type TraceDirection = 'CPE→ACS' | 'ACS→CPE'
export interface TraceEntry {
deviceId: string
manufacturer: string
oui: string
productClass: string
serialNumber: string
startedAt: string
addedBy?: string
}
interface TraceConfig {
entries: TraceEntry[]
}
export class Tr069TraceService {
private static instance: Tr069TraceService
private traceMap = new Map<string, TraceEntry>()
private writeQueues = new Map<string, Promise<void>>()
private constructor() {
this.loadConfig()
}
static getInstance(): Tr069TraceService {
if (!Tr069TraceService.instance) {
Tr069TraceService.instance = new Tr069TraceService()
}
return Tr069TraceService.instance
}
private loadConfig(): void {
try {
const raw = readFileSync(CONFIG_PATH, 'utf-8')
const config = JSON.parse(raw) as TraceConfig
this.traceMap.clear()
for (const entry of config.entries ?? []) {
this.traceMap.set(entry.deviceId, entry)
}
} catch {
this.traceMap.clear()
}
}
private async persistConfig(): Promise<void> {
const config: TraceConfig = { entries: Array.from(this.traceMap.values()) }
const tmpPath = `${CONFIG_PATH}.tmp`
mkdirSync(join(APP_ROOT, 'data'), { recursive: true })
await writeFile(tmpPath, JSON.stringify(config, null, 2), 'utf-8')
renameSync(tmpPath, CONFIG_PATH)
}
isTraced(deviceId: string): boolean {
return this.traceMap.has(deviceId)
}
list(): TraceEntry[] {
return Array.from(this.traceMap.values())
}
async addDevice(entry: Omit<TraceEntry, 'startedAt'> & { startedAt?: string }): Promise<void> {
const traceEntry: TraceEntry = {
...entry,
startedAt: entry.startedAt ?? new Date().toISOString(),
}
this.traceMap.set(entry.deviceId, traceEntry)
await this.persistConfig()
}
async removeDevice(deviceId: string): Promise<boolean> {
const existed = this.traceMap.delete(deviceId)
if (existed) {
await this.persistConfig()
}
return existed
}
reload(): void {
this.loadConfig()
}
writeExchange(deviceId: string, direction: TraceDirection, sessionId: string, xml: string): void {
const tail = this.writeQueues.get(deviceId) ?? Promise.resolve()
const next = tail.then(() => this.doWrite(deviceId, direction, sessionId, xml)).catch(() => undefined)
this.writeQueues.set(deviceId, next)
}
private async doWrite(deviceId: string, direction: TraceDirection, sessionId: string, xml: string): Promise<void> {
mkdirSync(LOG_DIR, { recursive: true })
const filePath = join(LOG_DIR, `${deviceId}.log`)
this.rotateIfNeeded(filePath)
const timestamp = new Date().toISOString()
const separator = '═'.repeat(80)
const entry = `\n${separator}\n[${timestamp}] ${direction} | ${sessionId}\n${separator}\n${xml}\n`
appendFileSync(filePath, entry, 'utf-8')
}
private rotateIfNeeded(filePath: string): void {
try {
const { size } = statSync(filePath)
if (size < MAX_FILE_BYTES) {
return
}
for (let index = MAX_ROTATED_FILES - 1; index >= 1; index--) {
try {
renameSync(`${filePath}.${index}`, `${filePath}.${index + 1}`)
} catch {
// Ignore missing rotated files.
}
}
renameSync(filePath, `${filePath}.1`)
} catch {
// Ignore missing log file.
}
}
}