ok
This commit is contained in:
1 parent
6082a0e610
commit
b3b1edec17
10 files changed
+373
-231
No files matched your search
@@ -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' })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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é
|
||||
|
||||
@@ -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: '' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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…</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…' : '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…</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…</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…' : '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">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div v-if="loadingLog && !logContent" class="text-muted small p-3">Loading log…</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…
|
||||
</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">✕</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…</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…
|
||||
<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…</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>
|
||||
@@ -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'])
|
||||
|
||||
|
||||
Reference in new issue
Block a user