This commit is contained in:
Kevin L'Hereec committed 2026-06-01 15:49:09 +02:00
1 parent 6082a0e610
commit b3b1edec17
10 files changed
+373 -231

No files matched your search

+46 -2
View File
@@ -2,10 +2,13 @@ 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'
import { readFileSync, existsSync } from 'node:fs'
import { readFileSync, existsSync, mkdirSync } from 'node:fs'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
const LOG_DIR = join(process.cwd(), 'logs', 'tr069')
const APP_ROOT = process.cwd()
const CONFIG_PATH = join(APP_ROOT, 'data', 'tr069-trace.json')
const LOG_DIR = join(APP_ROOT, 'logs', 'tr069')
const MAX_TAIL_LINES = 1000
export default class Tr069TraceController {
@@ -77,4 +80,45 @@ export default class Tr069TraceController {
return response.status(200).json({ deviceId, content: tail, lines: allLines.length })
}
async rawConfig({ response }: HttpContext) {
try {
if (!existsSync(CONFIG_PATH)) {
return response.status(200).json({ raw: JSON.stringify({ entries: [] }, null, 2) })
}
const raw = readFileSync(CONFIG_PATH, 'utf-8')
return response.status(200).json({ raw })
} catch (error) {
return response.status(500).json({ error: 'Failed to read config file' })
}
}
async saveRawConfig({ request, response }: HttpContext) {
const raw = request.input('raw') as string | undefined
if (!raw) {
return response.status(422).json({ error: 'raw JSON content is required' })
}
try {
const parsed = JSON.parse(raw)
if (!parsed.entries || !Array.isArray(parsed.entries)) {
return response.status(422).json({ error: 'JSON must contain an "entries" array' })
}
mkdirSync(join(APP_ROOT, 'data'), { recursive: true })
await writeFile(CONFIG_PATH, JSON.stringify(parsed, null, 2), 'utf-8')
// Reload the service in-memory state
this.service.reload()
logger.info('TR-069 trace config updated via raw editor')
return response.status(200).json({ entries: this.service.list() })
} catch (error) {
if (error instanceof SyntaxError) {
return response.status(422).json({ error: `Invalid JSON: ${error.message}` })
}
return response.status(500).json({ error: 'Failed to save config file' })
}
}
}
+4 -2
View File
@@ -26,9 +26,11 @@ export default class DeviceModel extends BaseModel {
filter: Filter<any> = {}
): Promise<{ data: DeviceRecord[]; total: number }> {
const collection = this.getCollection()
const total = await collection.countDocuments(filter)
// Only return documents created by the ACS (must have an identifier field)
const safeFilter: Filter<any> = { ...filter, identifier: { $exists: true, $ne: null } }
const total = await collection.countDocuments(safeFilter)
const data = (await collection
.find(filter, { projection: { _id: 0 } })
.find(safeFilter, { projection: { _id: 0 } })
.skip(offset)
.limit(limit)
.sort({ updatedAt: -1 } as Sort)
@@ -87,7 +87,8 @@ export class ConfigurationEvaluationState {
private async processNextAction(context: DeviceContext): Promise<DeviceStateResult> {
// 1. Inventory phase 1: GetParameterNames per subtree path
if (this.inventoryQueue.length > 0) {
// Skip if device does not support GPN (avoids useless round-trips that will fail)
if (this.inventoryQueue.length > 0 && context.device.gpnStatus !== 'unsupported') {
const path = this.inventoryQueue.shift()!
logger.debug({ identifier: context.device.identifier, path }, '[ConfigEval] Inventory GetParameterNames')
const response = await context.services.deviceAction.getParameterNames(
@@ -106,6 +107,10 @@ export class ConfigurationEvaluationState {
}
)
return { response }
} else if (this.inventoryQueue.length > 0) {
// GPN unsupported — clear inventory queue and skip to next phase
logger.debug({ identifier: context.device.identifier, skipped: this.inventoryQueue.length }, '[ConfigEval] Skipping inventory (GPN unsupported)')
this.inventoryQueue = []
}
// 2. Inventory phase 2: GetParameterValues for discovered leaf names
@@ -1,8 +1,18 @@
import { Device } from '../../domain/device/device.js'
import { DeviceParameter } from '../../domain/device/device_parameter.js'
import { ParameterType } from '../../domain/shared/types.js'
import { CWMPTaskQueue, ResultStatus, TaskResult } from './task_queue.js'
import { normalizePath } from '../parameter_writability.js'
import DeviceModel from '../../models/device.js'
import logger from '@adonisjs/core/services/logger'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const tr181Catalog: Array<{ path: string; writable: boolean; deviceClass: string }> =
require('../../../config/tr181-catalog.json')
const catalogWritableIndex = new Set(
tr181Catalog.filter((e) => e.writable).map((e) => e.path)
)
// Parameter types (simplified from legacy provisioning domain)
interface SetParameterValue {
@@ -82,6 +92,34 @@ export class DeviceActionExecutor {
}
}
/**
* Reclassify device properties as parameters using the TR-181 catalog.
* Called when GPN is unsupported — properties that are writable per the catalog
* should be moved to parameters so the configuration engine can write to them.
*/
private reclassifyFromCatalog(device: Device): void {
const properties = device.getProperties()
let reclassified = 0
for (const prop of properties) {
const normalized = normalizePath(prop.name)
if (catalogWritableIndex.has(normalized)) {
// Move from properties to parameters (writable)
const snap = prop.toSnapshot()
device.setParameter(
new DeviceParameter(snap.name, snap.value, snap.type, snap.createdAt, snap.updatedAt, snap.notification, snap.accessList)
)
// Remove from properties map
;(device as any)._properties.delete(prop.name)
reclassified++
}
}
if (reclassified > 0) {
logger.debug({ identifier: device.identifier, reclassified }, '[DeviceAction] Reclassified properties → parameters via catalog')
}
}
async getParameterValues(
device: Device,
parameterPaths: string[],
@@ -169,6 +207,8 @@ export class DeviceActionExecutor {
const code = typeof faultCode === 'string' ? parseInt(faultCode, 10) : faultCode
if (code === 9000 || code === 9001) {
device.setGpnStatus('unsupported')
// Reclassify parameters using TR-181 catalog writability
this.reclassifyFromCatalog(device)
await this.persistDevice(device)
}
// Autres erreurs (réseau, timeout, 9002+) → gpnStatus inchangé
+8 -4
View File
@@ -40,11 +40,15 @@ class IdleState extends DeviceState {
name = 'idle'
async handle(context: DeviceContext): Promise<DeviceStateResult> {
const parameterQueue = context.device.tmpParamValues.map((p: any) => p.Name).filter((n: any): n is string => !!n)
if (parameterQueue.length === 0) {
return { nextState: new ConfigurationEvaluationState(), response: '' }
// Test GPN support using the ManagementServer object path (not leaf params from Inform)
const dataModel = context.device.dataModel
if (dataModel === 'tr-181') {
return { nextState: new DiscoveringParametersState(['Device.ManagementServer.']), response: '' }
} else if (dataModel === 'tr-098') {
return { nextState: new DiscoveringParametersState(['InternetGatewayDevice.ManagementServer.']), response: '' }
}
return { nextState: new DiscoveringParametersState(parameterQueue), response: '' }
// Unknown data model — skip GPN test
return { nextState: new ConfigurationEvaluationState(), response: '' }
}
}
+37 -4
View File
@@ -264,7 +264,43 @@ export class StateMachineManager extends EventEmitter {
if (!machineInstance || machineInstance.status !== MachineStatus.RUNNING) return ''
try {
// Serve queued tasks first
// If a task is in-flight and CPE sends empty body, it means the CPE
// cannot/will not respond to our request. Treat as task failure.
if (machineInstance.lastTaskId) {
const failedTaskId = machineInstance.lastTaskId
machineInstance.lastTaskId = undefined
logger.warn(
{ session, taskId: failedTaskId },
'CPE sent empty body while task in-flight — treating as task failure'
)
this.taskQueue.failTask(failedTaskId, 'CPE sent empty body (no response)')
try {
await this.taskQueue.executeTaskCallback(failedTaskId, ResultStatus.Error, {
FaultCode: 9000,
FaultString: 'CPE did not respond (empty body)',
})
} catch (cbErr) {
logger.error(`Task callback error on empty-body failure: ${cbErr}`)
}
// Let the state machine handle the failure and advance
const response = await machineInstance.stateMachine.handleResponse(false, {
FaultCode: 9000,
FaultString: 'CPE did not respond (empty body)',
})
machineInstance.currentState = machineInstance.stateMachine.getCurrentStateName()
if (machineInstance.stateMachine.isCompleted()) {
this.completeMachine(machineInstance)
return ''
}
return response
}
// Serve queued tasks
const pendingTask = this.taskQueue.getTaskForDevice(machineInstance.deviceId)
if (pendingTask) {
const builder = CWMPTaskQueue.task_map[pendingTask.task]
@@ -275,9 +311,6 @@ export class StateMachineManager extends EventEmitter {
}
}
// If a task is in-flight, wait
if (machineInstance.lastTaskId) return ''
// Let the state machine continue
const response = await machineInstance.stateMachine.handleResponse(true, { isFirst: false })
machineInstance.currentState = machineInstance.stateMachine.getCurrentStateName()
+5
View File
@@ -59,6 +59,7 @@ export class Tr069TraceService {
const config: TraceConfig = { entries: Array.from(this.traceMap.values()) }
const tmpPath = `${CONFIG_PATH}.tmp`
mkdirSync(join(APP_ROOT, 'data'), { recursive: true })
await writeFile(tmpPath, JSON.stringify(config, null, 2), 'utf-8')
renameSync(tmpPath, CONFIG_PATH)
}
@@ -91,6 +92,10 @@ export class Tr069TraceService {
return existed
}
reload(): void {
this.loadConfig()
}
writeExchange(deviceId: string, direction: TraceDirection, sessionId: string, xml: string): void {
const tail = this.writeQueues.get(deviceId) ?? Promise.resolve()
const next = tail.then(() => this.doWrite(deviceId, direction, sessionId, xml)).catch(() => undefined)
+3 -3
View File
@@ -31,9 +31,9 @@ services:
ADD_IP_AS_HEADER_NAME: ""
IPV4_DENY_PREFIX: ""
IPV6_PREFIXES: ""
# MongoDB partagée avec l'ACS
MONGO_URL: "mongodb://adam:adam@mongodb:27017/adam-acs?authSource=admin"
MONGO_DATABASE: "adam-acs"
# MongoDB séparée pour le simulateur (évite les collisions de collections avec l'ACS)
MONGO_URL: "mongodb://adam:adam@mongodb:27017/home-device-simulator?authSource=admin"
MONGO_DATABASE: "home-device-simulator"
MONGO_CONNECTION_TIMEOUT: "10"
MONGO_SOCKET_TIMEOUT: "30"
MONGO_MAX_CONNECTION_IDLE_TIME: "60"
+222 -215
View File
@@ -11,25 +11,11 @@ type TraceEntry = {
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
// Log viewer state
const selectedDeviceId = ref<string | null>(null)
@@ -39,10 +25,14 @@ const loadingLog = ref(false)
const logError = ref('')
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
// JSON editor modal state
const showJsonModal = ref(false)
const jsonContent = ref('')
const jsonError = ref('')
const savingJson = ref(false)
const loadingJson = ref(false)
const tracedDeviceIds = computed(() => new Set(tracedDevices.value.map((entry) => entry.deviceId)))
const filteredSearchResults = computed(() =>
searchResults.value.filter((device) => !tracedDeviceIds.value.has(device.identifier))
)
const selectedEntry = computed(() =>
tracedDevices.value.find((e) => e.deviceId === selectedDeviceId.value) ?? null
)
@@ -51,16 +41,6 @@ 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)
})
watch(selectedDeviceId, async (id) => {
stopAutoRefresh()
logContent.value = ''
@@ -101,52 +81,6 @@ async function loadEntries() {
}
}
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((v) => v.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 = ''
@@ -189,164 +123,237 @@ async function loadLog(deviceId: string) {
function clearLog() {
logContent.value = ''
}
// JSON editor modal functions
async function openJsonEditor() {
showJsonModal.value = true
loadingJson.value = true
jsonError.value = ''
try {
const response = await fetch('/api/v1/tr069-trace/config')
if (!response.ok) throw new Error('Failed to load config')
const data = await response.json()
jsonContent.value = data.raw
} catch (error) {
jsonError.value = error instanceof Error ? error.message : 'Failed to load config'
} finally {
loadingJson.value = false
}
}
function closeJsonEditor() {
showJsonModal.value = false
jsonContent.value = ''
jsonError.value = ''
}
async function saveJsonConfig() {
savingJson.value = true
jsonError.value = ''
try {
// Validate JSON locally first
JSON.parse(jsonContent.value)
} catch (e) {
jsonError.value = `Invalid JSON: ${(e as Error).message}`
savingJson.value = false
return
}
try {
const response = await fetch('/api/v1/tr069-trace/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ raw: jsonContent.value }),
})
if (!response.ok) {
const payload = await response.json().catch(() => ({}))
throw new Error(payload.error || 'Failed to save config')
}
closeJsonEditor()
await loadEntries()
} catch (error) {
jsonError.value = error instanceof Error ? error.message : 'Failed to save config'
} finally {
savingJson.value = false
}
}
function formatJson() {
try {
const parsed = JSON.parse(jsonContent.value)
jsonContent.value = JSON.stringify(parsed, null, 2)
jsonError.value = ''
} catch (e) {
jsonError.value = `Invalid JSON: ${(e as Error).message}`
}
}
</script>
<template>
<TitleBar section="trace" />
<div class="container-xxl py-4">
<div class="row g-4">
<!-- Add device -->
<div class="col-12 col-xl-4">
<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&hellip;</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</th>
<th>Serial</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&hellip;' : 'Add' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="container-fluid py-4 px-4">
<!-- Traced devices table (full width) -->
<div class="card border-0 shadow-sm">
<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">Click a row to view its live SOAP log.</p>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-dark">{{ tracedDevices.length }}</span>
<button class="btn btn-sm btn-outline-primary" @click="openJsonEditor">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M5.854 4.854a.5.5 0 1 0-.708-.708l-3.5 3.5a.5.5 0 0 0 0 .708l3.5 3.5a.5.5 0 0 0 .708-.708L2.707 8l3.147-3.146zm4.292 0a.5.5 0 0 1 .708-.708l3.5 3.5a.5.5 0 0 1 0 .708l-3.5 3.5a.5.5 0 0 1-.708-.708L13.293 8l-3.147-3.146z"/>
</svg>
Edit JSON
</button>
</div>
</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&hellip;</p>
<div v-else class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Device ID</th>
<th>Manufacturer</th>
<th>Product Class</th>
<th>Serial Number</th>
<th>OUI</th>
<th>Started</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
<tr
v-for="entry in tracedDevices"
:key="entry.deviceId"
:class="{ 'table-active': selectedDeviceId === entry.deviceId }"
style="cursor: pointer"
@click="selectedDeviceId = entry.deviceId"
>
<td class="font-monospace small">{{ entry.deviceId }}</td>
<td>{{ entry.manufacturer || '—' }}</td>
<td>{{ entry.productClass || '—' }}</td>
<td>{{ entry.serialNumber || '—' }}</td>
<td>{{ entry.oui || '—' }}</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.stop="removeDevice(entry.deviceId)"
>
{{ removingDeviceId === entry.deviceId ? 'Removing…' : 'Remove' }}
</button>
</td>
</tr>
<tr v-if="tracedDevices.length === 0">
<td colspan="7" class="text-center text-muted py-4">
No traced devices configured. Click "Edit JSON" to add devices.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Traced devices + log viewer -->
<div class="col-12 col-xl-8">
<div class="card border-0 shadow-sm">
<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">Click a row to view its live SOAP log.</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&hellip;</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">Actions</th>
</tr>
</thead>
<tbody>
<tr
v-for="entry in tracedDevices"
:key="entry.deviceId"
:class="{ 'table-active': selectedDeviceId === entry.deviceId }"
style="cursor: pointer"
@click="selectedDeviceId = 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.stop="removeDevice(entry.deviceId)"
>
{{ removingDeviceId === entry.deviceId ? 'Removing&hellip;' : '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>
<!-- Log viewer panel (full width) -->
<div v-if="selectedDeviceId" class="card border-0 shadow-sm mt-4">
<div
class="card-header d-flex justify-content-between align-items-center bg-dark text-white py-2"
>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-success">Live</span>
<span class="small font-monospace">
{{ selectedEntry?.manufacturer }} {{ selectedEntry?.productClass }} /
{{ selectedEntry?.serialNumber }}
</span>
</div>
<div class="d-flex align-items-center gap-2">
<span class="small text-white-50">{{ logLines.toLocaleString() }} lines total</span>
<button class="btn btn-sm btn-outline-light py-0" @click="clearLog">Clear view</button>
<button class="btn btn-sm btn-outline-light py-0" @click="selectedDeviceId = null">&#x2715;</button>
</div>
</div>
<div class="card-body p-0">
<div v-if="loadingLog && !logContent" class="text-muted small p-3">Loading log&hellip;</div>
<div v-else-if="logError" class="alert alert-danger m-3">{{ logError }}</div>
<div v-else-if="!logContent" class="text-muted small p-3">
No log entries yet. Waiting for SOAP exchanges&hellip;
</div>
<pre
v-else
class="mb-0 p-3 small font-monospace"
style="background:#1e1e1e;color:#d4d4d4;max-height:600px;overflow-y:auto;white-space:pre-wrap;word-break:break-all"
>{{ logContent }}</pre>
</div>
</div>
</div>
<!-- Log viewer panel -->
<div v-if="selectedDeviceId" class="card border-0 shadow-sm mt-4">
<div
class="card-header d-flex justify-content-between align-items-center bg-dark text-white py-2"
>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-success">Live</span>
<span class="small font-monospace">
{{ selectedEntry?.manufacturer }} {{ selectedEntry?.productClass }} /
{{ selectedEntry?.serialNumber }}
</span>
</div>
<div class="d-flex align-items-center gap-2">
<span class="small text-white-50">{{ logLines.toLocaleString() }} lines total</span>
<button class="btn btn-sm btn-outline-light py-0" @click="clearLog">Clear view</button>
<button class="btn btn-sm btn-outline-light py-0" @click="selectedDeviceId = null">&#x2715;</button>
</div>
<!-- JSON Editor Modal -->
<Teleport to="body">
<div v-if="showJsonModal" class="modal-backdrop fade show" @click="closeJsonEditor"></div>
<div
v-if="showJsonModal"
class="modal fade show d-block"
tabindex="-1"
role="dialog"
aria-labelledby="jsonEditorModalLabel"
aria-modal="true"
>
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 id="jsonEditorModalLabel" class="modal-title">Edit Trace Configuration</h5>
<button type="button" class="btn-close" aria-label="Close" @click="closeJsonEditor"></button>
</div>
<div class="card-body p-0">
<div v-if="loadingLog && !logContent" class="text-muted small p-3">Loading log&hellip;</div>
<div v-else-if="logError" class="alert alert-danger m-3">{{ logError }}</div>
<div v-else-if="!logContent" class="text-muted small p-3">
No log entries yet. Waiting for SOAP exchanges&hellip;
<div class="modal-body">
<p class="text-muted small mb-2">
Edit the JSON configuration directly. Each entry in the <code>entries</code> array represents a traced device.
</p>
<div class="mb-2">
<strong class="small">Expected format:</strong>
<pre class="small bg-light p-2 rounded mt-1 mb-0">{{ '{ "entries": [{ "deviceId": "...", "manufacturer": "...", "oui": "...", "productClass": "...", "serialNumber": "...", "startedAt": "..." }] }' }}</pre>
</div>
<pre
<div v-if="loadingJson" class="text-muted small py-4 text-center">Loading configuration&hellip;</div>
<textarea
v-else
class="mb-0 p-3 small font-monospace"
style="background:#1e1e1e;color:#d4d4d4;max-height:600px;overflow-y:auto;white-space:pre-wrap;word-break:break-all"
>{{ logContent }}</pre>
v-model="jsonContent"
class="form-control font-monospace"
rows="16"
spellcheck="false"
style="font-size: 0.8rem; tab-size: 2"
></textarea>
<div v-if="jsonError" class="alert alert-danger mt-2 mb-0 small">{{ jsonError }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-outline-secondary" @click="formatJson">
Format JSON
</button>
<button type="button" class="btn btn-sm btn-secondary" @click="closeJsonEditor">
Cancel
</button>
<button
type="button"
class="btn btn-sm btn-primary"
:disabled="savingJson || loadingJson"
@click="saveJsonConfig"
>
{{ savingJson ? 'Saving…' : 'Save & Apply' }}
</button>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
+2
View File
@@ -36,6 +36,8 @@ router
router.get('/tr069-trace', [Tr069TraceController, 'index'])
router.post('/tr069-trace', [Tr069TraceController, 'store'])
router.get('/tr069-trace/config', [Tr069TraceController, 'rawConfig'])
router.put('/tr069-trace/config', [Tr069TraceController, 'saveRawConfig'])
router.delete('/tr069-trace/:id', [Tr069TraceController, 'destroy'])
router.get('/tr069-trace/:id/log', [Tr069TraceController, 'showLog'])