feat: per-device TR-069 SOAP trace logging

- Tr069TraceService: in-memory Map O(1) lookup, Promise-queue per device
  for sequential file writes, 10MB rotation (5 files kept)
- config/tr069-trace.json: persisted list of traced devices (atomic write)
- CwmpLoggerMiddleware: buffers request+response, writes to device log file
  after next() once ctx.cwmpDeviceId is set by controller
- CwmpController: sets ctx.cwmpDeviceId on all code paths (Inform + others)
- StateMachineManager: getHashIdForRemote() for non-Inform messages
- REST API: GET/POST/DELETE /api/v1/tr069-trace
- UI: Tr069Trace Vue page with device search, add/remove tracing
- 8 new unit tests (192 total, all passing)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kevin L'HereecandCopilot committed 2026-06-01 12:07:07 +02:00
1 parent a20aa81cfd
commit 847da1cdf5
23 files changed
+1024 -110

No files matched your search

+1
View File
@@ -1,6 +1,7 @@
# Dependencies and AdonisJS build
node_modules
build
logs
tmp/*
!tmp/.gitkeep
+2
View File
@@ -0,0 +1,2 @@
/cache
/project.local.yml
+1
View File
@@ -0,0 +1 @@
ADAM ACS is a TR-069 Auto-Configuration Server built with AdonisJS + TypeScript on the backend and Vue 3 + Inertia on the frontend. It manages devices, commands, configurations, firmware catalog flows, and CWMP/TR-069 SOAP handling. Main server code lives in app/, routes in start/routes.ts, frontend pages in inertia/pages/, shared localization in inertia/locales/, tests in tests/unit, tests/functional, tests/integration, and tests/property. Mongo-backed models live under app/models with domain objects under app/domain.
@@ -0,0 +1 @@
Codebase uses TypeScript/ESM with .js import extensions in source files. Prefer strong typing, small service/controller methods, and existing AdonisJS patterns such as lazy controller imports in routes and class-based middleware/controllers. Frontend uses Vue 3 <script setup lang="ts">, Boosted/Bootstrap classes, and vue-i18n strings from inertia/locales/*.json. Existing code often uses direct fetch calls in pages and any in older areas; avoid broad refactors outside the task. Prettier formatting is enforced by ESLint/prettier rules.
+12
View File
@@ -0,0 +1,12 @@
Useful Linux/project commands:
- cd /home/pgzm5392/workspaces/adam-acs
- ls, find, grep, git --no-pager status
- npm run dev # start Adonis dev server with HMR
- npm run build # production build
- npm run typecheck # TypeScript check
- npm run lint # ESLint
- npm test # Adonis test runner
- npx vitest run tests/unit/ # unit tests
- npx tsc --noEmit # direct TS compile check
- node ace test # test runner alias
- node ace serve --hmr # dev server alias
+1
View File
@@ -0,0 +1 @@
When a task is complete, run targeted verification first and then project-level checks when relevant. For backend/frontend code changes here, at minimum run npx tsc --noEmit and the relevant Vitest/Japa tests. Use npm run lint or eslint on changed files to catch formatting/type-import issues; note the repo may contain pre-existing lint findings outside the current task. If JSON/config/meta files are added, ensure build metadata (adonisrc.ts metaFiles) still includes what production needs.
+154
View File
@@ -0,0 +1,154 @@
# the name by which the project can be referenced within Serena
project_name: "adam-acs"
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# haxe java julia kotlin lua
# markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- vue
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
#
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project based on the project name or path.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly,
# for example by saying that the information retrieved from a memory file is no longer correct
# or no longer relevant for the project.
# * `edit_memory`: Replaces content matching a regular expression in a memory.
# * `execute_shell_command`: Executes a shell command.
# * `find_file`: Finds files in the given relative paths
# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend
# * `find_symbol`: Performs a global (or local) search using the language server backend.
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual')
# for clients that do not read the initial instructions when the MCP server is connected.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Read the content of a memory file. This tool should only be used if the information
# is relevant to the current task. You can infer whether the information
# is relevant from the memory file name.
# You should not read the same memory file multiple times in the same conversation.
# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported
# (e.g., renaming "global/foo" to "bar" moves it from global to project scope).
# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities.
# For JB, we use a separate tool.
# * `replace_content`: Replaces content in a file (optionally using regular expressions).
# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend.
# * `safe_delete_symbol`:
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
# The memory name should be meaningful.
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as read‑only.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
+4 -12
View File
@@ -24,10 +24,7 @@ export default defineConfig({
| will be scanned automatically from the "./commands" directory.
|
*/
commands: [
() => import('@adonisjs/core/commands'),
() => import('@adonisjs/session/commands'),
],
commands: [() => import('@adonisjs/core/commands'), () => import('@adonisjs/session/commands')],
/*
|--------------------------------------------------------------------------
@@ -64,10 +61,7 @@ export default defineConfig({
| List of modules to import before starting the application.
|
*/
preloads: [
() => import('#start/routes'),
() => import('#start/kernel'),
],
preloads: [() => import('#start/routes'), () => import('#start/kernel')],
/*
|--------------------------------------------------------------------------
@@ -117,13 +111,11 @@ export default defineConfig({
{ pattern: 'resources/views/**/*.edge', reloadServer: false },
{ pattern: 'public/**', reloadServer: false },
{ pattern: 'resources/xsd/**', reloadServer: false },
{ pattern: 'config/**/*.json', reloadServer: false },
],
hooks: {
init: [
indexEntities({ transformers: { enabled: true } }),
generateRegistry(),
],
init: [indexEntities({ transformers: { enabled: true } }), generateRegistry()],
buildStarting: [() => import('@adonisjs/vite/build_hook')],
},
})
+43 -16
View File
@@ -30,14 +30,16 @@ export default class CwmpController {
* - Validated content-type
* - Attached everything to request.cwmp
*/
async handle({ request, response }: HttpContext) {
async handle(ctx: HttpContext) {
const { request, response } = ctx
const manager = this.getManager()
const { sessionId, remoteKey, envelope, message, isEmpty } = request.cwmp
try {
// Empty body = CPE polling, continue the state machine
if (isEmpty) {
manager.ensureSessionForRemote(remoteKey, sessionId)
const hashId = manager.getHashIdForRemote(remoteKey)
if (hashId) ctx.cwmpDeviceId = hashId
if (manager.hasSession(sessionId)) {
const result = await manager.continue(sessionId)
@@ -48,20 +50,31 @@ export default class CwmpController {
return response.status(204).send('')
}
// Handle Inform
if (message?.type === 'Inform') {
const result = await this.handleInform(message.data, sessionId, envelope?.header?.id, remoteKey, manager)
return this.sendSoap(response, result)
const { xml, hashId } = await this.handleInform(
message.data,
sessionId,
envelope?.header?.id,
remoteKey,
manager
)
ctx.cwmpDeviceId = hashId
return this.sendSoap(response, xml)
}
// Handle TransferComplete
if (message?.type === 'TransferComplete') {
const resp = buildSoapEnvelope(undefined, '<cwmp:TransferCompleteResponse></cwmp:TransferCompleteResponse>')
const hashId = manager.getHashIdForRemote(remoteKey)
if (hashId) ctx.cwmpDeviceId = hashId
const resp = buildSoapEnvelope(
undefined,
'<cwmp:TransferCompleteResponse></cwmp:TransferCompleteResponse>'
)
return this.sendSoap(response, resp)
}
// Other CWMP responses - route to state machine
manager.ensureSessionForRemote(remoteKey, sessionId)
const hashId = manager.getHashIdForRemote(remoteKey)
if (hashId) ctx.cwmpDeviceId = hashId
if (message && manager.hasSession(sessionId)) {
const { success, payload } = this.getMessageInfo(message)
@@ -73,7 +86,6 @@ export default class CwmpController {
return response.status(204).send('')
} catch (error: any) {
// Return SOAP Fault per TR-069 spec (always HTTP 200)
logger.error({ error: error?.message, stack: error?.stack }, 'CWMP error handling request')
const faultXml = this.buildSoapFault(error?.message || 'Internal error')
return this.sendSoap(response, faultXml)
@@ -88,8 +100,11 @@ export default class CwmpController {
cwmpId: string | undefined,
remoteKey: string,
manager: StateMachineManager
): Promise<string> {
logger.info({ informKeys: Object.keys(informData || {}), deviceId: informData?.DeviceId }, 'CWMP handleInform called')
): Promise<{ xml: string; hashId: string }> {
logger.info(
{ informKeys: Object.keys(informData || {}), deviceId: informData?.DeviceId },
'CWMP handleInform called'
)
const deviceIdStruct = informData?.DeviceId || {}
const deviceId = DeviceId.fromInform(deviceIdStruct)
@@ -122,10 +137,13 @@ export default class CwmpController {
gpnStatus: device.gpnStatus,
updatedAt: new Date(),
})
logger.info({ hashId }, 'Inform received')
// Extract events
const events: string[] = Array.isArray(informData?.Event?.EventStruct)
? informData.Event.EventStruct.map((e: any) => e?.EventCode).filter((c: any): c is string => !!c)
? informData.Event.EventStruct.map((e: any) => e?.EventCode).filter(
(c: any): c is string => !!c
)
: []
logger.info({ events }, 'CWMP Inform events extracted')
@@ -150,7 +168,7 @@ export default class CwmpController {
}
}
return CWMPMessageBuilder.informResponse(cwmpId)
return { xml: CWMPMessageBuilder.informResponse(cwmpId), hashId }
}
// ─── Helpers ───────────────────────────────────────────────────────────────
@@ -189,12 +207,21 @@ export default class CwmpController {
if (!record) return null
const device = new Device(
new DeviceId(record.deviceId.manufacturer, record.deviceId.oui, record.deviceId.productClass, record.deviceId.serialNumber),
new DeviceId(
record.deviceId.manufacturer,
record.deviceId.oui,
record.deviceId.productClass,
record.deviceId.serialNumber
),
record.createdAt
)
record.properties?.forEach((p: any) => device.setProperty(new DeviceProperty(p.name, p.value, p.type)))
record.parameters?.forEach((p: any) => device.setParameter(new DeviceParameter(p.name, p.value, p.type)))
record.properties?.forEach((p: any) =>
device.setProperty(new DeviceProperty(p.name, p.value, p.type))
)
record.parameters?.forEach((p: any) =>
device.setParameter(new DeviceParameter(p.name, p.value, p.type))
)
device.setGpnStatus(record.gpnStatus ?? 'unknown')
return device
+15 -3
View File
@@ -24,8 +24,11 @@ export default class PagesController {
.slice(0, 5)
const lastProcessed = allCommands
.filter((c: any) => ['COMPLETED', 'FAILED'].includes(c.status))
.sort((a: any, b: any) => new Date(b.updatedAt || b.createdAt).getTime() - new Date(a.updatedAt || a.createdAt).getTime())
[0]
.sort(
(a: any, b: any) =>
new Date(b.updatedAt || b.createdAt).getTime() -
new Date(a.updatedAt || a.createdAt).getTime()
)[0]
const commands = lastProcessed ? [...pending, lastProcessed] : pending
return (inertia as any).render('DeviceDetail', { device, commands })
@@ -54,7 +57,12 @@ export default class PagesController {
return (inertia as any).render('Commands', {
commands,
filters: { device: deviceFilter, status: statusFilter, action: actionFilter, mode: modeFilter },
filters: {
device: deviceFilter,
status: statusFilter,
action: actionFilter,
mode: modeFilter,
},
})
}
@@ -64,6 +72,10 @@ export default class PagesController {
return (inertia as any).render('CommandsFleet', { devices, commands })
}
async tr069Trace({ inertia }: HttpContext) {
return (inertia as any).render('Tr069Trace')
}
async storeFleetDevices({ request, response, session }: HttpContext) {
const { devices } = request.body()
;(session as any).put('fleetDevices', devices || [])
+55
View File
@@ -0,0 +1,55 @@
import type { HttpContext } from '@adonisjs/core/http'
import logger from '@adonisjs/core/services/logger'
import DeviceModel from '#models/device'
import { Tr069TraceService } from '#services/tr069_trace_service'
export default class Tr069TraceController {
private get service(): Tr069TraceService {
return Tr069TraceService.getInstance()
}
async index({ response }: HttpContext) {
return response.status(200).json({ entries: this.service.list() })
}
async store({ request, response }: HttpContext) {
const deviceId = request.input('deviceId') as string | undefined
if (!deviceId) {
return response.status(422).json({ error: 'deviceId is required' })
}
if (this.service.isTraced(deviceId)) {
return response.status(409).json({ error: 'Device is already being traced' })
}
const record = await DeviceModel.findByIdentifier(deviceId)
if (!record) {
return response.status(404).json({ error: `Device ${deviceId} not found` })
}
await this.service.addDevice({
deviceId,
manufacturer: record.deviceId?.manufacturer ?? '',
oui: record.deviceId?.oui ?? '',
productClass: record.deviceId?.productClass ?? '',
serialNumber: record.deviceId?.serialNumber ?? '',
})
logger.info({ deviceId }, 'TR-069 trace enabled for device')
const entry = this.service.list().find((traceEntry) => traceEntry.deviceId === deviceId)
return response.status(201).json({ entry })
}
async destroy({ params, response }: HttpContext) {
const removed = await this.service.removeDevice(params.id)
if (!removed) {
return response.status(404).json({ error: `Device ${params.id} is not being traced` })
}
logger.info({ deviceId: params.id }, 'TR-069 trace disabled for device')
return response.status(204).send('')
}
}
+37 -17
View File
@@ -2,38 +2,41 @@ import type { HttpContext } from '@adonisjs/core/http'
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'
/**
* CWMP Logger Middleware
*
* Middleware dédié à l'observation des messages TR-069 XML.
* Trace les échanges CPE↔ACS (requêtes entrantes et réponses sortantes)
* avec le XML pretty-printed directement sur stdout pour lisibilité humaine.
*
* Doit être placé avant le cwmp_middleware dans la chaîne.
* - Logs all SOAP exchanges to stdout for debugging.
* - For traced devices, writes full SOAP payloads to logs/tr069/{deviceId}.log.
*/
export default class CwmpLoggerMiddleware {
private prettyXml(xml: string): string {
try {
return (formatXml as any)(xml, { indentation: ' ', collapseContent: true, lineSeparator: '\n' })
return (formatXml as any)(xml, {
indentation: ' ',
collapseContent: true,
lineSeparator: '\n',
})
} catch {
return xml
}
}
private log(direction: string, sessionId: string, xml: string) {
private logToStdout(direction: string, sessionId: string, xml: string) {
const timestamp = new Date().toISOString()
const separator = '─'.repeat(80)
process.stdout.write(
`\n${separator}\n` +
`[TR-069] ${direction} | ${timestamp} | ${sessionId}\n` +
`${separator}\n` +
`${xml}\n` +
`${separator}\n\n`
`[TR-069] ${direction} | ${timestamp} | ${sessionId}\n` +
`${separator}\n` +
`${xml}\n` +
`${separator}\n\n`
)
}
async handle({ request, response }: HttpContext, next: NextFn) {
async handle(ctx: HttpContext, next: NextFn) {
const { request, response } = ctx
const rawBody = request.raw() || ''
const socket = request.request?.socket
@@ -43,20 +46,37 @@ export default class CwmpLoggerMiddleware {
const localPort = socket?.localPort || 0
const sessionId = `${remoteIp}:${remotePort}->${localAddress}:${localPort}`
// Log incoming TR-069 message
if (rawBody && rawBody.trim() !== '') {
this.log('CPE→ACS', sessionId, this.prettyXml(rawBody))
this.logToStdout('CPE→ACS', sessionId, this.prettyXml(rawBody))
}
// Intercept response to log outgoing XML
let capturedResponseXml: string | null = null
const originalSend = response.send.bind(response)
response.send = (body: any) => {
if (typeof body === 'string' && body.includes('soap-env:Envelope')) {
this.log('ACS→CPE', sessionId, this.prettyXml(body))
this.logToStdout('ACS→CPE', sessionId, this.prettyXml(body))
capturedResponseXml = body
}
return originalSend(body)
}
return next()
await next()
const deviceId = ctx.cwmpDeviceId
if (!deviceId) {
return
}
const traceService = Tr069TraceService.getInstance()
if (!traceService.isTraced(deviceId)) {
return
}
if (rawBody && rawBody.trim() !== '') {
traceService.writeExchange(deviceId, 'CPE→ACS', this.prettyXml(rawBody))
}
if (capturedResponseXml) {
traceService.writeExchange(deviceId, 'ACS→CPE', this.prettyXml(capturedResponseXml))
}
}
}
+35 -6
View File
@@ -1,6 +1,6 @@
import { EventEmitter } from 'node:events'
import { Device } from '../../domain/device/device.js'
import { DeviceId } from '../../domain/device/device_id.js'
import type { Device } from '../../domain/device/device.js'
import type { DeviceId } from '../../domain/device/device_id.js'
import { CWMPTaskQueue, ResultStatus } from './task_queue.js'
import { MainStateMachine } from './main_state_machine.js'
import { ImmediateCommandStateMachine } from './immediate_command_state_machine.js'
@@ -113,7 +113,9 @@ export class StateMachineManager extends EventEmitter {
const existingMachine = this.machines.get(existingMachineId)
if (existingMachine && existingMachine.status === MachineStatus.RUNNING) {
const isImmediate = existingMachine.stateMachine instanceof ImmediateCommandStateMachine
const onboardingEvent = ['0 BOOTSTRAP', '1 BOOT', '2 PERIODIC', '3 SCHEDULED'].includes(lastEvent)
const onboardingEvent = ['0 BOOTSTRAP', '1 BOOT', '2 PERIODIC', '3 SCHEDULED'].includes(
lastEvent
)
const shouldPreempt = isImmediate && (forceMainForNI || onboardingEvent)
if (shouldPreempt) {
@@ -154,7 +156,10 @@ export class StateMachineManager extends EventEmitter {
stateMachine = new MainStateMachine(device, session, this.taskQueue)
} else if (lastEvent === '6 CONNECTION REQUEST') {
stateMachine = new ImmediateCommandStateMachine(device, session, this.taskQueue)
} else if (['0 BOOTSTRAP', '1 BOOT', '2 PERIODIC', '3 SCHEDULED'].includes(lastEvent) || !lastEvent) {
} else if (
['0 BOOTSTRAP', '1 BOOT', '2 PERIODIC', '3 SCHEDULED'].includes(lastEvent) ||
!lastEvent
) {
stateMachine = new MainStateMachine(device, session, this.taskQueue)
} else {
// Unknown event - use MainStateMachine as default
@@ -201,6 +206,27 @@ export class StateMachineManager extends EventEmitter {
this.remoteToDevice.set(remoteKey, JSON.stringify(deviceId))
}
/**
* Returns the hash identifier for the device bound to a remote endpoint.
* Used by trace logging middleware to correlate non-Inform messages.
*/
getHashIdForRemote(remoteKey: string): string | undefined {
const deviceKeyJson = this.remoteToDevice.get(remoteKey)
if (!deviceKeyJson) return undefined
try {
const deviceId = JSON.parse(deviceKeyJson)
return getDeviceHashIdentifier({
manufacturer: deviceId.manufacturer ?? '',
oui: deviceId.oui,
productClass: deviceId.productClass,
serialNumber: deviceId.serialNumber,
})
} catch {
return undefined
}
}
/**
* Ensure session is bound when TCP connection changes
*/
@@ -354,8 +380,11 @@ export class StateMachineManager extends EventEmitter {
cleanupCompletedMachines(olderThanHours: number = 1): void {
const cutoff = new Date(Date.now() - olderThanHours * 60 * 60 * 1000)
for (const [id, machine] of this.machines.entries()) {
if ((machine.status === MachineStatus.COMPLETED || machine.status === MachineStatus.FAILED) &&
machine.completedAt && machine.completedAt < cutoff) {
if (
(machine.status === MachineStatus.COMPLETED || machine.status === MachineStatus.FAILED) &&
machine.completedAt &&
machine.completedAt < cutoff
) {
this.machines.delete(id)
}
}
+134
View File
@@ -0,0 +1,134 @@
import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
const CONFIG_PATH = fileURLToPath(new URL('../../config/tr069-trace.json', import.meta.url))
const LOG_DIR = fileURLToPath(new URL('../../logs/tr069', import.meta.url))
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`
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
}
writeExchange(deviceId: string, direction: TraceDirection, xml: string): void {
const tail = this.writeQueues.get(deviceId) ?? Promise.resolve()
const next = tail.then(() => this.doWrite(deviceId, direction, xml)).catch(() => undefined)
this.writeQueues.set(deviceId, next)
}
private async doWrite(deviceId: string, direction: TraceDirection, 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}\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.
}
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"entries": []
}
+3 -2
View File
@@ -3,7 +3,7 @@ import { useI18n } from 'vue-i18n'
const { t } = useI18n()
type SectionKey = 'devices' | 'configurations' | 'commands' | 'device' | 'firmware'
type SectionKey = 'devices' | 'configurations' | 'commands' | 'device' | 'firmware' | 'trace'
const { section, subtitle } = defineProps<{
section: SectionKey
@@ -16,13 +16,14 @@ const sectionColors: Record<SectionKey, string> = {
commands: 'orange',
device: 'green',
firmware: 'purple',
trace: 'orange',
}
</script>
<template>
<div :class="['bg-supporting-' + sectionColors[section]]" data-bs-theme="light">
<div class="container-xxl py-3">
<h1 class="display-1 mb-0" style="letter-spacing: normal;">
<h1 class="display-1 mb-0" style="letter-spacing: normal">
{{ t(`titles.${section}`) }}
<span v-if="subtitle" class="fs-5 fw-bold ms-2">{{ subtitle }}</span>
</h1>
+32 -12
View File
@@ -40,9 +40,15 @@ function isActive(paths: string | string[]): boolean {
<h1 class="navbar-brand mb-0 me-3">
<Link href="/">
<span class="visually-hidden">{{ t('header.title') }}</span>
<svg width="30" height="30" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect width="100" height="100" fill="#ff7900"/>
<rect x="14.29" y="71.43" width="71.43" height="14.29" fill="#fff"/>
<svg
width="30"
height="30"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<rect width="100" height="100" fill="#ff7900" />
<rect x="14.29" y="71.43" width="71.43" height="14.29" fill="#fff" />
</svg>
</Link>
</h1>
@@ -66,7 +72,12 @@ function isActive(paths: string | string[]): boolean {
<Link
href="/"
class="nav-link"
:class="{ active: isActive('/') && !page.url.startsWith('/configurations') && !page.url.startsWith('/commands') }"
:class="{
active:
isActive('/') &&
!page.url.startsWith('/configurations') &&
!page.url.startsWith('/commands'),
}"
>
{{ t('header.devices') }}
</Link>
@@ -90,14 +101,19 @@ function isActive(paths: string | string[]): boolean {
</Link>
</li>
<li class="nav-item">
<Link
href="/commands"
class="nav-link"
:class="{ active: isActive('/commands') }"
>
<Link href="/commands" class="nav-link" :class="{ active: isActive('/commands') }">
{{ t('header.commands') }}
</Link>
</li>
<li class="nav-item">
<Link
href="/tr069-trace"
class="nav-link"
:class="{ active: isActive('/tr069-trace') }"
>
{{ t('header.tr069Trace') }}
</Link>
</li>
</ul>
<!-- Right: language dropdown -->
@@ -144,7 +160,7 @@ function isActive(paths: string | string[]): boolean {
</header>
<!-- Main Content -->
<main class="flex-grow-1" style="background-color: #f6f6f6;">
<main class="flex-grow-1" style="background-color: #f6f6f6">
<slot />
</main>
@@ -154,8 +170,12 @@ function isActive(paths: string | string[]): boolean {
<div class="container-xxl footer-terms">
<ul class="navbar-nav flex-row gap-md-3">
<li class="fw-bold text-white">© Orange 2025</li>
<li><a class="nav-link" href="#">{{ t('footer.legal') }}</a></li>
<li><a class="nav-link" href="#">{{ t('footer.privacy') }}</a></li>
<li>
<a class="nav-link" href="#">{{ t('footer.legal') }}</a>
</li>
<li>
<a class="nav-link" href="#">{{ t('footer.privacy') }}</a>
</li>
</ul>
</div>
</footer>
+5 -3
View File
@@ -4,7 +4,8 @@
"devices": "Devices",
"configurations": "Configurations",
"firmware": "Firmware",
"commands": "Commands"
"commands": "Commands",
"tr069Trace": "TR-069 Trace"
},
"titles": {
"devices": "Devices",
@@ -15,7 +16,8 @@
"commandsFleet": "Fleet Commands",
"discoveryCreate": "Create Discovery Configuration",
"provisioningCreate": "Create Provisioning Configuration",
"firmware": "Firmware Catalog"
"firmware": "Firmware Catalog",
"trace": "TR-069 Trace"
},
"labels": {
"manufacturer": "Manufacturer",
@@ -191,4 +193,4 @@
"noParamDiffs": "No parameter differences.",
"firmwarePath": "Firmware migration path"
}
}
}
+5 -3
View File
@@ -4,7 +4,8 @@
"devices": "Équipements",
"configurations": "Configurations",
"firmware": "Firmware",
"commands": "Commandes"
"commands": "Commandes",
"tr069Trace": "Trace TR-069"
},
"titles": {
"devices": "Équipements",
@@ -15,7 +16,8 @@
"commandsFleet": "Commandes flotte",
"discoveryCreate": "Créer une configuration de découverte",
"provisioningCreate": "Créer une configuration de provisionnement",
"firmware": "Catalogue Firmware"
"firmware": "Catalogue Firmware",
"trace": "Trace TR-069"
},
"labels": {
"manufacturer": "Fabricant",
@@ -191,4 +193,4 @@
"noParamDiffs": "Aucune différence de paramètres.",
"firmwarePath": "Chemin de migration firmware"
}
}
}
+282
View File
@@ -0,0 +1,282 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import TitleBar from '~/components/TitleBar.vue'
type TraceEntry = {
deviceId: string
manufacturer: string
oui: string
productClass: string
serialNumber: string
startedAt: string
}
type DeviceRecord = {
identifier: string
deviceId?: {
manufacturer?: string
productClass?: string
serialNumber?: string
}
}
const tracedDevices = ref<TraceEntry[]>([])
const searchTerm = ref('')
const searchResults = ref<DeviceRecord[]>([])
const loadingEntries = ref(false)
const loadingSearch = ref(false)
const savingDeviceId = ref<string | null>(null)
const removingDeviceId = ref<string | null>(null)
const entriesError = ref('')
const actionError = ref('')
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const tracedDeviceIds = computed(() => new Set(tracedDevices.value.map((entry) => entry.deviceId)))
const filteredSearchResults = computed(() =>
searchResults.value.filter((device) => !tracedDeviceIds.value.has(device.identifier))
)
onMounted(async () => {
await loadEntries()
})
watch(searchTerm, (value) => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
if (!value.trim()) {
searchResults.value = []
actionError.value = ''
return
}
searchTimeout = setTimeout(() => {
void searchDevices(value)
}, 250)
})
async function loadEntries() {
loadingEntries.value = true
entriesError.value = ''
try {
const response = await fetch('/api/v1/tr069-trace')
if (!response.ok) {
throw new Error('Failed to load trace entries')
}
const data = await response.json()
tracedDevices.value = data.entries || []
} catch (error) {
entriesError.value = error instanceof Error ? error.message : 'Failed to load trace entries'
} finally {
loadingEntries.value = false
}
}
async function searchDevices(query: string) {
loadingSearch.value = true
actionError.value = ''
try {
const response = await fetch('/api/v1/devices?offset=0&limit=200')
if (!response.ok) {
throw new Error('Failed to search devices')
}
const devices = (await response.json()) as DeviceRecord[]
const normalizedQuery = query.trim().toLowerCase()
searchResults.value = devices.filter((device) => {
const manufacturer = device.deviceId?.manufacturer?.toLowerCase() ?? ''
const productClass = device.deviceId?.productClass?.toLowerCase() ?? ''
const serialNumber = device.deviceId?.serialNumber?.toLowerCase() ?? ''
const identifier = device.identifier.toLowerCase()
return [manufacturer, productClass, serialNumber, identifier].some((value) =>
value.includes(normalizedQuery)
)
})
} catch (error) {
actionError.value = error instanceof Error ? error.message : 'Failed to search devices'
searchResults.value = []
} finally {
loadingSearch.value = false
}
}
async function addDevice(deviceId: string) {
savingDeviceId.value = deviceId
actionError.value = ''
try {
const response = await fetch('/api/v1/tr069-trace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId }),
})
if (!response.ok) {
const payload = await response.json().catch(() => ({}))
throw new Error(payload.error || 'Failed to enable tracing')
}
searchTerm.value = ''
searchResults.value = []
await loadEntries()
} catch (error) {
actionError.value = error instanceof Error ? error.message : 'Failed to enable tracing'
} finally {
savingDeviceId.value = null
}
}
async function removeDevice(deviceId: string) {
removingDeviceId.value = deviceId
actionError.value = ''
try {
const response = await fetch(`/api/v1/tr069-trace/${encodeURIComponent(deviceId)}`, {
method: 'DELETE',
})
if (!response.ok) {
const payload = await response.json().catch(() => ({}))
throw new Error(payload.error || 'Failed to disable tracing')
}
await loadEntries()
} catch (error) {
actionError.value = error instanceof Error ? error.message : 'Failed to disable tracing'
} finally {
removingDeviceId.value = null
}
}
</script>
<template>
<TitleBar section="trace" />
<div class="container-xxl py-4">
<div class="row g-4">
<div class="col-12 col-xl-5">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<h2 class="h4 mb-3">Add device</h2>
<p class="text-muted small mb-3">
Search by manufacturer, product class, serial number, or device identifier.
</p>
<label for="trace-device-search" class="form-label">Search device</label>
<input
id="trace-device-search"
v-model="searchTerm"
type="search"
class="form-control"
placeholder="Search devices..."
/>
<p v-if="loadingSearch" class="text-muted small mt-3 mb-0">Searching devices…</p>
<p
v-else-if="searchTerm.trim() && filteredSearchResults.length === 0"
class="text-muted small mt-3 mb-0"
>
No matching devices found.
</p>
<div v-if="filteredSearchResults.length > 0" class="table-responsive mt-3">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>Manufacturer</th>
<th>Product Class</th>
<th>Serial Number</th>
<th class="text-end">Action</th>
</tr>
</thead>
<tbody>
<tr v-for="device in filteredSearchResults" :key="device.identifier">
<td>{{ device.deviceId?.manufacturer || '—' }}</td>
<td>{{ device.deviceId?.productClass || '—' }}</td>
<td>{{ device.deviceId?.serialNumber || '—' }}</td>
<td class="text-end">
<button
class="btn btn-sm btn-primary"
:disabled="savingDeviceId === device.identifier"
@click="addDevice(device.identifier)"
>
{{ savingDeviceId === device.identifier ? 'Adding…' : 'Add' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-7">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h2 class="h4 mb-1">Traced devices</h2>
<p class="text-muted small mb-0">
Full SOAP traces are written to per-device files.
</p>
</div>
<span class="badge bg-dark">{{ tracedDevices.length }}</span>
</div>
<div v-if="entriesError" class="alert alert-danger" role="alert">
{{ entriesError }}
</div>
<div v-if="actionError" class="alert alert-warning" role="alert">
{{ actionError }}
</div>
<p v-if="loadingEntries" class="text-muted small mb-0">Loading traced devices…</p>
<div v-else class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Manufacturer</th>
<th>Product Class</th>
<th>Serial Number</th>
<th>Started</th>
<th class="text-end">Action</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in tracedDevices" :key="entry.deviceId">
<td>{{ entry.manufacturer || '—' }}</td>
<td>{{ entry.productClass || '—' }}</td>
<td>{{ entry.serialNumber || '—' }}</td>
<td>{{ new Date(entry.startedAt).toLocaleString() }}</td>
<td class="text-end">
<button
class="btn btn-sm btn-outline-danger"
:disabled="removingDeviceId === entry.deviceId"
@click="removeDevice(entry.deviceId)"
>
{{ removingDeviceId === entry.deviceId ? 'Removing…' : 'Remove' }}
</button>
</td>
</tr>
<tr v-if="tracedDevices.length === 0">
<td colspan="5" class="text-center text-muted py-4">
No traced devices configured.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
+44 -36
View File
@@ -9,6 +9,7 @@ const FirmwareCatalogController = () => import('#controllers/firmware_catalog_co
const HealthController = () => import('#controllers/health_controller')
const PagesController = () => import('#controllers/pages_controller')
const CwmpController = () => import('#controllers/cwmp_controller')
const Tr069TraceController = () => import('#controllers/tr069_trace_controller')
// CWMP/TR-069 endpoint (SOAP/XML) - uses dedicated middleware for XML parsing
router.post('/cwmp10', [CwmpController, 'handle']).use([middleware.cwmpLogger(), middleware.cwmp()])
@@ -18,45 +19,51 @@ router.get('/api/v1/checklife', [HealthController, 'checklife'])
router.get('/api/v1/checkservice', [HealthController, 'checkservice'])
// REST API routes
router.group(() => {
// Devices
router.get('/devices', [DevicesController, 'index'])
router.get('/devices/criteria-values', [DevicesController, 'criteriaValues'])
router.get('/devices/:identifier', [DevicesController, 'show'])
router.delete('/devices/:identifier', [DevicesController, 'destroy'])
router.post('/devices/:identifier/command', [DevicesController, 'executeCommand'])
router.post('/devices/:identifier/refresh', [DevicesController, 'refresh'])
router
.group(() => {
// Devices
router.get('/devices', [DevicesController, 'index'])
router.get('/devices/criteria-values', [DevicesController, 'criteriaValues'])
router.get('/devices/:identifier', [DevicesController, 'show'])
router.delete('/devices/:identifier', [DevicesController, 'destroy'])
router.post('/devices/:identifier/command', [DevicesController, 'executeCommand'])
router.post('/devices/:identifier/refresh', [DevicesController, 'refresh'])
// Commands
router.get('/commands', [CommandsController, 'index'])
router.get('/commands/:id', [CommandsController, 'show'])
router.post('/commands', [CommandsController, 'store'])
// Commands
router.get('/commands', [CommandsController, 'index'])
router.get('/commands/:id', [CommandsController, 'show'])
router.post('/commands', [CommandsController, 'store'])
// Unified Configuration API (v1)
router.get('/configurations', [ConfigurationsV1Controller, 'index'])
router.get('/configurations/tags', [ConfigurationsV1Controller, 'listTags'])
router.post('/configurations', [ConfigurationsV1Controller, 'store'])
router.get('/configurations/:id', [ConfigurationsV1Controller, 'show'])
router.put('/configurations/:id', [ConfigurationsV1Controller, 'update'])
router.delete('/configurations/:id', [ConfigurationsV1Controller, 'destroy'])
router.patch('/configurations/:id/toggle', [ConfigurationsV1Controller, 'toggleEnabled'])
router.patch('/configurations/:id/tags', [ConfigurationsV1Controller, 'manageTags'])
router.get('/configurations/:id/history', [ConfigurationsV1Controller, 'history'])
router.post('/configurations/:id/dry-run', [ConfigurationsV1Controller, 'dryRun'])
router.get('/configurations/:id/compliance', [ConfigurationsV1Controller, 'compliance'])
router.get('/tr069-trace', [Tr069TraceController, 'index'])
router.post('/tr069-trace', [Tr069TraceController, 'store'])
router.delete('/tr069-trace/:id', [Tr069TraceController, 'destroy'])
// Firmware Catalog API (v1)
router.get('/firmware-catalog', [FirmwareCatalogController, 'index'])
router.get('/firmware-catalog/entries', [FirmwareCatalogController, 'listEntries'])
router.get('/firmware-catalog/entries/:id', [FirmwareCatalogController, 'showEntry'])
router.get('/firmware-catalog/routes', [FirmwareCatalogController, 'listRoutes'])
router.post('/firmware-catalog/entries', [FirmwareCatalogController, 'createEntry'])
router.put('/firmware-catalog/entries/:id', [FirmwareCatalogController, 'updateEntry'])
router.delete('/firmware-catalog/entries/:id', [FirmwareCatalogController, 'deleteEntry'])
router.post('/firmware-catalog/routes', [FirmwareCatalogController, 'createRoute'])
router.put('/firmware-catalog/routes/:id', [FirmwareCatalogController, 'updateRoute'])
router.delete('/firmware-catalog/routes/:id', [FirmwareCatalogController, 'deleteRoute'])
}).prefix('/api/v1')
// Unified Configuration API (v1)
router.get('/configurations', [ConfigurationsV1Controller, 'index'])
router.get('/configurations/tags', [ConfigurationsV1Controller, 'listTags'])
router.post('/configurations', [ConfigurationsV1Controller, 'store'])
router.get('/configurations/:id', [ConfigurationsV1Controller, 'show'])
router.put('/configurations/:id', [ConfigurationsV1Controller, 'update'])
router.delete('/configurations/:id', [ConfigurationsV1Controller, 'destroy'])
router.patch('/configurations/:id/toggle', [ConfigurationsV1Controller, 'toggleEnabled'])
router.patch('/configurations/:id/tags', [ConfigurationsV1Controller, 'manageTags'])
router.get('/configurations/:id/history', [ConfigurationsV1Controller, 'history'])
router.post('/configurations/:id/dry-run', [ConfigurationsV1Controller, 'dryRun'])
router.get('/configurations/:id/compliance', [ConfigurationsV1Controller, 'compliance'])
// Firmware Catalog API (v1)
router.get('/firmware-catalog', [FirmwareCatalogController, 'index'])
router.get('/firmware-catalog/entries', [FirmwareCatalogController, 'listEntries'])
router.get('/firmware-catalog/entries/:id', [FirmwareCatalogController, 'showEntry'])
router.get('/firmware-catalog/routes', [FirmwareCatalogController, 'listRoutes'])
router.post('/firmware-catalog/entries', [FirmwareCatalogController, 'createEntry'])
router.put('/firmware-catalog/entries/:id', [FirmwareCatalogController, 'updateEntry'])
router.delete('/firmware-catalog/entries/:id', [FirmwareCatalogController, 'deleteEntry'])
router.post('/firmware-catalog/routes', [FirmwareCatalogController, 'createRoute'])
router.put('/firmware-catalog/routes/:id', [FirmwareCatalogController, 'updateRoute'])
router.delete('/firmware-catalog/routes/:id', [FirmwareCatalogController, 'deleteRoute'])
})
.prefix('/api/v1')
// Inertia page routes
router.get('/', [PagesController, 'home'])
@@ -65,6 +72,7 @@ router.post('/devices/:identifier/refresh', [PagesController, 'refreshDevice'])
router.post('/devices/:identifier/get-model', [PagesController, 'getModel'])
router.get('/commands', [PagesController, 'commands']).as('commands.page')
router.get('/commands/fleet', [PagesController, 'commandsFleet'])
router.get('/tr069-trace', [PagesController, 'tr069Trace'])
router.post('/commands/fleet', [PagesController, 'storeFleetDevices'])
router.post('/commands/fleet/command', [PagesController, 'storeFleetCommand'])
@@ -0,0 +1,148 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const fsMocks = vi.hoisted(() => ({
appendFileSync: vi.fn(),
mkdirSync: vi.fn(),
readFileSync: vi.fn(),
renameSync: vi.fn(),
statSync: vi.fn(),
}))
const fsPromisesMocks = vi.hoisted(() => ({
writeFile: vi.fn(),
}))
vi.mock('node:fs', () => ({
appendFileSync: fsMocks.appendFileSync,
mkdirSync: fsMocks.mkdirSync,
readFileSync: fsMocks.readFileSync,
renameSync: fsMocks.renameSync,
statSync: fsMocks.statSync,
}))
vi.mock('node:fs/promises', () => ({
writeFile: fsPromisesMocks.writeFile,
}))
import { Tr069TraceService } from '../../../app/services/tr069_trace_service.js'
describe('Tr069TraceService', () => {
const baseEntry = {
deviceId: 'device-123',
manufacturer: 'Orange',
oui: 'A1B2C3',
productClass: 'Livebox',
serialNumber: 'SN123456',
}
beforeEach(() => {
vi.clearAllMocks()
fsMocks.readFileSync.mockImplementation(() => {
throw new Error('missing config')
})
fsMocks.statSync.mockImplementation(() => {
throw new Error('missing log')
})
;(Tr069TraceService as any).instance = undefined
})
it('returns false for untracked devices', () => {
const service = Tr069TraceService.getInstance()
expect(service.isTraced('unknown-device')).toBe(false)
})
it('returns true after adding a device', async () => {
const service = Tr069TraceService.getInstance()
await service.addDevice(baseEntry)
expect(service.isTraced(baseEntry.deviceId)).toBe(true)
})
it('lists all traced devices', async () => {
const service = Tr069TraceService.getInstance()
await service.addDevice(baseEntry)
await service.addDevice({
...baseEntry,
deviceId: 'device-456',
serialNumber: 'SN654321',
})
expect(service.list()).toHaveLength(2)
expect(service.list().map((entry) => entry.deviceId)).toEqual(['device-123', 'device-456'])
})
it('adds a device with an auto-generated startedAt value', async () => {
const service = Tr069TraceService.getInstance()
await service.addDevice(baseEntry)
const [entry] = service.list()
expect(entry.startedAt).toBeTypeOf('string')
expect(Number.isNaN(Date.parse(entry.startedAt))).toBe(false)
})
it('removes a traced device and returns true', async () => {
const service = Tr069TraceService.getInstance()
await service.addDevice(baseEntry)
await expect(service.removeDevice(baseEntry.deviceId)).resolves.toBe(true)
expect(service.isTraced(baseEntry.deviceId)).toBe(false)
})
it('returns false when removing an unknown device', async () => {
const service = Tr069TraceService.getInstance()
await expect(service.removeDevice('unknown-device')).resolves.toBe(false)
})
it('persists config after add and remove operations', async () => {
const service = Tr069TraceService.getInstance()
await service.addDevice(baseEntry)
await service.removeDevice(baseEntry.deviceId)
expect(fsPromisesMocks.writeFile).toHaveBeenCalledTimes(2)
expect(fsPromisesMocks.writeFile).toHaveBeenNthCalledWith(
1,
expect.stringContaining('config/tr069-trace.json.tmp'),
expect.stringContaining(baseEntry.deviceId),
'utf-8'
)
expect(fsPromisesMocks.writeFile).toHaveBeenNthCalledWith(
2,
expect.stringContaining('config/tr069-trace.json.tmp'),
JSON.stringify({ entries: [] }, null, 2),
'utf-8'
)
expect(fsMocks.renameSync).toHaveBeenCalledWith(
expect.stringContaining('config/tr069-trace.json.tmp'),
expect.stringContaining('config/tr069-trace.json')
)
})
it('rotates log files when size exceeds the limit', async () => {
const service = Tr069TraceService.getInstance()
fsMocks.statSync.mockReturnValue({ size: 10 * 1024 * 1024 })
await (service as any).doWrite(baseEntry.deviceId, 'CPE→ACS', '<Inform />')
expect(fsMocks.renameSync).toHaveBeenNthCalledWith(
1,
expect.stringContaining(`${baseEntry.deviceId}.log.4`),
expect.stringContaining(`${baseEntry.deviceId}.log.5`)
)
expect(fsMocks.renameSync).toHaveBeenLastCalledWith(
expect.stringContaining(`${baseEntry.deviceId}.log`),
expect.stringContaining(`${baseEntry.deviceId}.log.1`)
)
expect(fsMocks.appendFileSync).toHaveBeenCalledWith(
expect.stringContaining(`${baseEntry.deviceId}.log`),
expect.stringContaining('CPE→ACS'),
'utf-8'
)
})
})
+7
View File
@@ -0,0 +1,7 @@
declare module '@adonisjs/core/http' {
interface HttpContext {
cwmpDeviceId?: string
}
}
export {}