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:
1 parent
a20aa81cfd
commit
847da1cdf5
23 files changed
+1024
-110
No files matched your search
@@ -1,6 +1,7 @@
|
||||
# Dependencies and AdonisJS build
|
||||
node_modules
|
||||
build
|
||||
logs
|
||||
tmp/*
|
||||
!tmp/.gitkeep
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/cache
|
||||
/project.local.yml
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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')],
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 || [])
|
||||
|
||||
@@ -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('')
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"entries": []
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
declare module '@adonisjs/core/http' {
|
||||
interface HttpContext {
|
||||
cwmpDeviceId?: string
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
Reference in new issue
Block a user