- Ajout de fetchWithCsrf() helper qui lit le cookie XSRF-TOKEN et injecte X-XSRF-TOKEN sur toutes les requetes POST/PUT/PATCH/DELETE vers /internal/* - Remplacement de fetch() par fetchWithCsrf() dans tous les composants Vue concernes (Tr069Trace, DeviceDetail, SequenceViewer, Home, configurations, firmware_catalog) - Activation IPv6 sur les reseaux Docker (enable_ipv6: true, subnet fd00:adam::/64) dans docker-compose.yml et docker-compose.prod.yml - Ajout tests unitaires DeviceService.sendWakeOnWlan : IPv4, IPv6, ENETUNREACH, credentials fallback chain (11 tests)
640 lines
31 KiB
Vue
640 lines
31 KiB
Vue
<script setup lang="ts">
|
|
import { ref } from 'vue'
|
|
import { router, Link } from '@inertiajs/vue3'
|
|
import { useI18n } from 'vue-i18n'
|
|
import TitleBar from '../components/TitleBar.vue'
|
|
import { fetchWithCsrf } from '../helpers/fetch_with_csrf'
|
|
|
|
const { t } = useI18n()
|
|
|
|
const { device, modelPaths, commands } = defineProps<{
|
|
device: any
|
|
modelPaths: string[]
|
|
commands: any[]
|
|
}>()
|
|
|
|
const isLoading = ref(false)
|
|
const showDeleteConfirm = ref(false)
|
|
const isTraced = ref(false)
|
|
const tracingAction = ref(false)
|
|
|
|
async function checkTraceStatus() {
|
|
try {
|
|
const response = await fetch('/internal/tr069-trace')
|
|
if (response.ok) {
|
|
const data = await response.json()
|
|
isTraced.value = (data.entries || []).some((e: any) => e.deviceId === device.identifier)
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
async function toggleTrace() {
|
|
tracingAction.value = true
|
|
try {
|
|
if (isTraced.value) {
|
|
await fetchWithCsrf(`/internal/tr069-trace/${encodeURIComponent(device.identifier)}`, { method: 'DELETE' })
|
|
isTraced.value = false
|
|
} else {
|
|
const response = await fetchWithCsrf('/internal/tr069-trace', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ deviceId: device.identifier }),
|
|
})
|
|
if (response.ok) isTraced.value = true
|
|
}
|
|
} catch { /* ignore */ }
|
|
finally { tracingAction.value = false }
|
|
}
|
|
|
|
checkTraceStatus()
|
|
|
|
async function deleteDevice() {
|
|
await fetchWithCsrf(`/internal/devices/${encodeURIComponent(device.identifier)}`, { method: 'DELETE' })
|
|
router.visit('/')
|
|
}
|
|
const showGetModelPopup = ref(false)
|
|
const getModelInput = ref('')
|
|
const activeSection = ref('identifiers')
|
|
|
|
// Collapsible sections
|
|
const openSections = ref<Set<string>>(new Set(['identifiers', 'commands']))
|
|
|
|
function toggleSection(section: string) {
|
|
if (openSections.value.has(section)) {
|
|
openSections.value.delete(section)
|
|
} else {
|
|
openSections.value.add(section)
|
|
}
|
|
}
|
|
|
|
function isSectionOpen(section: string) {
|
|
return openSections.value.has(section)
|
|
}
|
|
|
|
function scrollTo(section: string) {
|
|
activeSection.value = section
|
|
document.getElementById(section)?.scrollIntoView({ behavior: 'smooth' })
|
|
}
|
|
|
|
function refreshDevice() {
|
|
isLoading.value = true
|
|
router.post(`/devices/${encodeURIComponent(device.identifier)}/refresh`, {}, {
|
|
preserveScroll: true,
|
|
onSuccess: () => {
|
|
setTimeout(() => {
|
|
isLoading.value = false
|
|
router.visit(`/devices/${encodeURIComponent(device.identifier)}`, { preserveScroll: true })
|
|
}, 2000)
|
|
},
|
|
onFinish: () => { isLoading.value = false },
|
|
})
|
|
}
|
|
|
|
function openGetModelPopup() {
|
|
getModelInput.value = modelPaths.length > 0 ? modelPaths[0] : ''
|
|
showGetModelPopup.value = true
|
|
}
|
|
|
|
function submitGetModel() {
|
|
if (!getModelInput.value.trim()) return
|
|
isLoading.value = true
|
|
router.post(`/devices/${encodeURIComponent(device.identifier)}/get-model`, {
|
|
modelPath: getModelInput.value.trim(),
|
|
}, {
|
|
preserveScroll: true,
|
|
onSuccess: () => {
|
|
showGetModelPopup.value = false
|
|
setTimeout(() => {
|
|
isLoading.value = false
|
|
router.visit(`/devices/${encodeURIComponent(device.identifier)}`, { preserveScroll: true })
|
|
}, 2000)
|
|
},
|
|
onFinish: () => { isLoading.value = false },
|
|
})
|
|
}
|
|
|
|
// Estimated next connection = lastPeriodicInformAt + PeriodicInformInterval
|
|
const estimatedNextConnection = (() => {
|
|
const lastPeriodic = device.lastPeriodicInformAt
|
|
if (!lastPeriodic) return null
|
|
const allParams = [...(device.properties || []), ...(device.parameters || [])].filter((p: any) => p && typeof p.name === 'string')
|
|
const intervalParam = allParams.find((p: any) => p.name.includes('PeriodicInformInterval'))
|
|
const intervalSeconds = intervalParam?.value ? parseInt(intervalParam.value, 10) : 0
|
|
if (!intervalSeconds || intervalSeconds <= 0) return null
|
|
const next = new Date(new Date(lastPeriodic).getTime() + intervalSeconds * 1000)
|
|
return next.toLocaleString()
|
|
})()
|
|
|
|
// Check if the device has a ConnectionRequestURL (needed for immediate mode / Refresh)
|
|
const hasConnectionRequestUrl = (() => {
|
|
const allParams = [...(device.properties || []), ...(device.parameters || [])].filter((p: any) => p && typeof p.name === 'string')
|
|
const crUrl = allParams.find(
|
|
(p: any) =>
|
|
p.name.endsWith('ManagementServer.ConnectionRequestURL') ||
|
|
p.name.endsWith('.ConnectionRequestURL')
|
|
)
|
|
return crUrl && crUrl.value && crUrl.value.trim() !== ''
|
|
})()
|
|
|
|
function formatName(name: unknown): string {
|
|
if (name == null) return ''
|
|
const str = typeof name === 'string' ? name : String(name)
|
|
return str.replace(/\./g, '.<wbr>')
|
|
}
|
|
|
|
function getPropertyValue(name: string): string {
|
|
const prop = device.properties?.find((p: any) => p.name === name)
|
|
return prop?.value ?? ''
|
|
}
|
|
|
|
// References resolution
|
|
const resolvingProvider = ref<string | null>(null)
|
|
|
|
async function resolveProvider(providerName: string) {
|
|
resolvingProvider.value = providerName
|
|
try {
|
|
await fetchWithCsrf(`/internal/devices/${encodeURIComponent(device.identifier)}/references/resolve/${providerName}`, { method: 'POST' })
|
|
router.visit(`/devices/${encodeURIComponent(device.identifier)}`, { preserveScroll: true })
|
|
} catch { /* ignore */ }
|
|
finally { resolvingProvider.value = null }
|
|
}
|
|
|
|
async function resolveAllProviders() {
|
|
resolvingProvider.value = '__all__'
|
|
try {
|
|
await fetchWithCsrf(`/internal/devices/${encodeURIComponent(device.identifier)}/references/resolve`, { method: 'POST' })
|
|
router.visit(`/devices/${encodeURIComponent(device.identifier)}`, { preserveScroll: true })
|
|
} catch { /* ignore */ }
|
|
finally { resolvingProvider.value = null }
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<TitleBar
|
|
section="device"
|
|
:subtitle="`${device.deviceId?.oui}_${device.deviceId?.productClass}_${device.deviceId?.serialNumber}`"
|
|
/>
|
|
|
|
<div class="container-xxl py-4">
|
|
<div class="row g-4">
|
|
<!-- Side Navigation -->
|
|
<div class="col-lg-1 d-none d-lg-block">
|
|
<nav class="sidenav sticky-top pt-3">
|
|
<ul class="nav flex-column">
|
|
<li class="nav-item">
|
|
<a class="nav-link" :class="{ active: activeSection === 'identifiers' }" href="javascript:void(0);" @click="scrollTo('identifiers')">
|
|
Identité
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" :class="{ active: activeSection === 'properties' }" href="javascript:void(0);" @click="scrollTo('properties')">
|
|
{{ t('labels.properties') }}
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" :class="{ active: activeSection === 'parameters' }" href="javascript:void(0);" @click="scrollTo('parameters')">
|
|
{{ t('labels.parameters') }}
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" :class="{ active: activeSection === 'actions' }" href="javascript:void(0);" @click="scrollTo('actions')">
|
|
Actions
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" :class="{ active: activeSection === 'commands' }" href="javascript:void(0);" @click="scrollTo('commands')">
|
|
{{ t('titles.commands') }}
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</nav>
|
|
</div>
|
|
|
|
<!-- Main Content -->
|
|
<div class="col-lg-11">
|
|
|
|
<!-- Section: Identité — une seule carte englobante -->
|
|
<div id="identifiers" class="mb-3">
|
|
<div class="card border-0 shadow-sm" style="background:#fff;border-radius:10px!important">
|
|
|
|
<!-- En-tête de la carte -->
|
|
<div class="px-3 pt-3 pb-2">
|
|
<span class="text-uppercase fw-bold" style="font-size:0.65rem;letter-spacing:.1em;color:#888">{{ t('device.identity') }}</span>
|
|
</div>
|
|
|
|
<div class="px-3 pb-3 d-flex flex-column gap-2">
|
|
|
|
<!-- Zone 1 : Identifiant -->
|
|
<div class="rounded-3 px-3 py-3" style="background:#f6f7f9">
|
|
<div class="text-muted fw-bold text-uppercase mb-3" style="font-size:0.62rem;letter-spacing:.08em">{{ t('device.identifier') }}</div>
|
|
<div class="row g-3">
|
|
<div class="col-sm-6 col-lg-3">
|
|
<div class="text-muted fw-bold text-uppercase mb-1" style="font-size:0.62rem;letter-spacing:.06em">{{ t('labels.manufacturer') }}</div>
|
|
<div class="fw-semibold fs-6">{{ device.deviceId?.manufacturer || '—' }}</div>
|
|
</div>
|
|
<div class="col-sm-6 col-lg-3">
|
|
<div class="text-muted fw-bold text-uppercase mb-1" style="font-size:0.62rem;letter-spacing:.06em">OUI</div>
|
|
<div class="fw-semibold fs-6 font-monospace">{{ device.deviceId?.oui || '—' }}</div>
|
|
</div>
|
|
<div class="col-sm-6 col-lg-3">
|
|
<div class="text-muted fw-bold text-uppercase mb-1" style="font-size:0.62rem;letter-spacing:.06em">{{ t('labels.productClass') }}</div>
|
|
<div class="fw-semibold fs-6">{{ device.deviceId?.productClass || '—' }}</div>
|
|
</div>
|
|
<div class="col-sm-6 col-lg-3">
|
|
<div class="text-muted fw-bold text-uppercase mb-1" style="font-size:0.62rem;letter-spacing:.06em">{{ t('labels.serialNumber') }}</div>
|
|
<div class="fw-semibold fs-6 font-monospace">{{ device.deviceId?.serialNumber || '—' }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Zones 2+3 : Versions + Système côte à côte -->
|
|
<div class="row g-2">
|
|
|
|
<!-- Zone 2 : Versions -->
|
|
<div class="col-lg-5">
|
|
<div class="rounded-3 px-3 py-3 h-100" style="background:#f6f7f9">
|
|
<div class="d-flex align-items-center gap-2 mb-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" viewBox="0 0 1000 1000" class="text-muted flex-shrink-0">
|
|
<path d="M500 75.5c-234.72 0-425 190.28-425 425s190.28 425 425 425 425-190.28 425-425-190.28-425-425-425m0 100a75 75 0 1 1-75 75 75 75 0 0 1 75-75m125 600H400v-25.737l5.287-.167c15.555-.5 27.115-4.919 34.368-13.136 2.576-2.972 6.956-13.1 6.956-47.153v-227.2c0-32.392-4.523-44.6-8.311-49.141-5.359-6.391-16.516-10.147-33.17-11.156l-5.13-.31v-26h175v313.807c0 32.415 8.457 44.618 12.24 49.142 5.34 6.389 15.68 10.142 32.625 11.152l5.135.3v25.6Z" style="fill-rule:evenodd"/>
|
|
</svg>
|
|
<span class="text-muted fw-bold text-uppercase" style="font-size:0.62rem;letter-spacing:.08em">{{ t('device.versions') }}</span>
|
|
</div>
|
|
<div class="row g-3">
|
|
<div class="col-6">
|
|
<div class="text-muted fw-bold text-uppercase mb-2" style="font-size:0.62rem;letter-spacing:.06em">{{ t('labels.softwareVersion') }}</div>
|
|
<span class="font-monospace fw-medium small">{{ getPropertyValue('InternetGatewayDevice.DeviceInfo.SoftwareVersion') || getPropertyValue('Device.DeviceInfo.SoftwareVersion') || '—' }}</span>
|
|
</div>
|
|
<div class="col-6">
|
|
<div class="text-muted fw-bold text-uppercase mb-2" style="font-size:0.62rem;letter-spacing:.06em">{{ t('labels.hardwareVersion') }}</div>
|
|
<span class="font-monospace fw-medium small">{{ getPropertyValue('InternetGatewayDevice.DeviceInfo.HardwareVersion') || getPropertyValue('Device.DeviceInfo.HardwareVersion') || '—' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Zone 3 : Système -->
|
|
<div class="col-lg-7">
|
|
<div class="rounded-3 px-3 py-3 h-100" style="background:#f6f7f9">
|
|
<div class="d-flex align-items-center gap-2 mb-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" viewBox="0 0 1000 1000" class="text-muted flex-shrink-0">
|
|
<path d="M827.568 500.5c0-110.61 120.715-26.921 64.516-162.6-56.231-135.681-82.391 8.823-160.606-69.384s66.333-104.365-69.36-160.562c-135.718-56.2-52.024 64.481-162.61 64.481-110.619 0-26.921-120.68-162.612-64.481s8.827 82.349-69.391 160.562-104.372-66.3-160.575 69.384c-56.23 135.679 64.486 51.99 64.486 162.6S50.7 527.419 106.93 663.1c56.2 135.681 82.359-8.823 160.575 69.352 78.218 78.214-66.3 104.4 69.391 160.6s51.993-64.483 162.612-64.483c110.586 0 26.892 120.678 162.61 64.483 135.693-56.2-8.854-82.384 69.36-160.6 78.215-78.175 104.375 66.329 160.606-69.352 56.199-135.681-64.516-51.99-64.516-162.6M500 301.015c110.457 0 200 89.537 200 199.985s-89.543 199.985-200 199.985S300 611.448 300 501s89.543-199.985 200-199.985" style="fill-rule:evenodd"/>
|
|
</svg>
|
|
<span class="text-muted fw-bold text-uppercase" style="font-size:0.62rem;letter-spacing:.08em">{{ t('device.system') }}</span>
|
|
</div>
|
|
<div class="row g-3">
|
|
<div class="col-md-6">
|
|
<div class="text-muted fw-bold text-uppercase mb-1" style="font-size:0.62rem;letter-spacing:.06em">{{ t('labels.identifier') }} (ACS)</div>
|
|
<div class="font-monospace small text-break">{{ device.identifier || '—' }}</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="text-muted fw-bold text-uppercase mb-1" style="font-size:0.62rem;letter-spacing:.06em">Provisioning Code</div>
|
|
<div class="font-monospace small">{{ device.provisioningCode || '—' }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- Zones 4+5+5b : dates sur une ligne -->
|
|
<div class="row g-2">
|
|
|
|
<!-- Zone 4 : Créé le -->
|
|
<div class="col-sm-4">
|
|
<div class="rounded-3 px-3 py-3 d-flex align-items-center gap-3" style="background:#f6f7f9">
|
|
<div class="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0"
|
|
style="width:34px;height:34px;background:#e4e6ea">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 1000 1000" class="text-muted">
|
|
<path d="M800 175H700v-38c0-20.71-16.789-37-37.5-37a37.5 37.5 0 0 0-37.5 37.5V175H350.011L350 137.5a37.5 37.5 0 0 0-75 0V175H100v625a75 75 0 0 0 75 75h700V250a75 75 0 0 0-75-75m25 650H187.5a37.5 37.5 0 0 1-37.5-37.5V400h637.5a37.5 37.5 0 0 1 37.5 37.5zM250 262.5a62.4 62.4 0 0 1 25-50v50a37.5 37.5 0 0 0 75 0V250h.011v-37.493A62.5 62.5 0 1 1 250 262.5m350 0a62.4 62.4 0 0 1 25-50v50a37.5 37.5 0 0 0 75 0l-.011-12.5H700v-37a61.47 61.47 0 0 1 25 49.5 62.5 62.5 0 0 1-125 0" style="fill-rule:evenodd"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div class="text-muted fw-bold text-uppercase mb-1" style="font-size:0.62rem;letter-spacing:.06em">{{ t('labels.createdAt') }}</div>
|
|
<div class="font-monospace small fw-medium">{{ device.createdAt ? new Date(device.createdAt).toLocaleString() : '—' }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Zone 5 : Dernière connexion -->
|
|
<div class="col-sm-4">
|
|
<div class="rounded-3 px-3 py-3 d-flex align-items-center gap-3" style="background:#f6f7f9">
|
|
<div class="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0"
|
|
style="width:34px;height:34px;background:#e4e6ea">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 1000 1000" class="text-muted">
|
|
<path d="M800 175H700v-38c0-20.71-16.789-37-37.5-37a37.5 37.5 0 0 0-37.5 37.5V175H350.011L350 137.5a37.5 37.5 0 0 0-75 0V175H100v625a75 75 0 0 0 75 75h700V250a75 75 0 0 0-75-75M300 825H187.5a37.5 37.5 0 0 1-37.5-37.5V400h150zm-50-562.5a62.4 62.4 0 0 1 25-50v50a37.5 37.5 0 0 0 75 0V250h.011v-37.493A62.5 62.5 0 1 1 250 262.5M475 825H325V400h150zm175 0H500V400h150zm-50-562.5a62.4 62.4 0 0 1 25-50v50a37.5 37.5 0 0 0 75 0l-.011-12.5H700v-37a61.47 61.47 0 0 1 25 49.5 62.5 62.5 0 0 1-125 0M825 825H675V400h112.5a37.5 37.5 0 0 1 37.5 37.5z" style="fill-rule:evenodd"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div class="text-muted fw-bold text-uppercase mb-1" style="font-size:0.62rem;letter-spacing:.06em">{{ t('labels.lastSeen') }}</div>
|
|
<div class="font-monospace small fw-medium">{{ device.updatedAt ? new Date(device.updatedAt).toLocaleString() : '—' }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Zone 5b : Prochaine connexion estimée -->
|
|
<div class="col-sm-4">
|
|
<div class="rounded-3 px-3 py-3 d-flex align-items-center gap-3" style="background:#f6f7f9">
|
|
<div class="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0"
|
|
style="width:34px;height:34px;background:#e4e6ea">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" class="text-muted">
|
|
<path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71z"/>
|
|
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div class="text-muted fw-bold text-uppercase mb-1" style="font-size:0.62rem;letter-spacing:.06em">{{ t('device.nextConnection') }}</div>
|
|
<div class="font-monospace small fw-medium">{{ estimatedNextConnection || '—' }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- Zone 6 : Références client -->
|
|
<div v-if="device.references && device.references.length > 0" class="rounded-3 px-3 py-3" style="background:#f6f7f9">
|
|
<div class="d-flex align-items-center gap-2 mb-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" viewBox="0 0 16 16" class="text-muted flex-shrink-0">
|
|
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6m2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0m4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 4m-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10s-3.516.68-4.168 1.332c-.678.678-.83 1.418-.832 1.664z"/>
|
|
</svg>
|
|
<span class="text-muted fw-bold text-uppercase" style="font-size:0.62rem;letter-spacing:.08em">{{ t('device.clientReferences') }}</span>
|
|
<button class="btn btn-link btn-sm p-0 ms-auto" @click="resolveAllProviders" :disabled="resolvingProvider !== null">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
|
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2z"/>
|
|
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div v-for="ref in device.references" :key="ref.provider" class="mb-2">
|
|
<div class="d-flex align-items-center gap-2 mb-2">
|
|
<span v-if="ref.status === 'ok'" style="color:#198754">●</span>
|
|
<span v-else-if="ref.status === 'error'" style="color:#dc3545">●</span>
|
|
<span v-else style="color:#6c757d">●</span>
|
|
<span class="fw-semibold small">{{ ref.provider }}</span>
|
|
<span class="text-muted small">{{ ref.resolvedBy }}</span>
|
|
<span class="text-muted small ms-auto">{{ ref.resolvedAt ? new Date(ref.resolvedAt).toLocaleString() : '' }}</span>
|
|
</div>
|
|
<div v-if="ref.error" class="text-danger small mb-1">{{ ref.error }}</div>
|
|
<div v-if="ref.status === 'ok' && ref.properties" class="row g-2">
|
|
<div v-for="(value, key) in ref.properties" :key="key" :class="'col'" :style="`flex: 0 0 ${100 / Object.keys(ref.properties).length}%; max-width: ${100 / Object.keys(ref.properties).length}%`">
|
|
<div class="text-muted fw-bold text-uppercase" style="font-size:0.58rem;letter-spacing:.06em">{{ key }}</div>
|
|
<div class="font-monospace small fw-medium text-truncate">{{ value || '—' }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Section: Properties (collapsible) -->
|
|
<div class="accordion-section mb-3" id="properties">
|
|
<div class="section-header" @click="toggleSection('properties')">
|
|
<h2 class="h6 mb-0">{{ t('labels.properties') }} ({{ device.properties?.filter((p: any) => typeof p.name === 'string' && p.value != null).length || 0 }})</h2>
|
|
<span class="chevron" :class="{ open: isSectionOpen('properties') }">▾</span>
|
|
</div>
|
|
<div v-if="isSectionOpen('properties')" class="section-body">
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-hover mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>{{ t('labels.name') }}</th>
|
|
<th>{{ t('labels.value') }}</th>
|
|
<th>{{ t('labels.type') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="prop in device.properties?.filter((p: any) => p && typeof p.name === 'string' && p.value != null)" :key="prop.name">
|
|
<td class="multi-line small" v-html="formatName(prop.name)"></td>
|
|
<td class="multi-line small font-monospace" v-html="formatName(prop.value)"></td>
|
|
<td class="small">{{ prop.type }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Section: Parameters (collapsible) -->
|
|
<div class="accordion-section mb-3" id="parameters">
|
|
<div class="section-header" @click="toggleSection('parameters')">
|
|
<h2 class="h6 mb-0">{{ t('labels.parameters') }} ({{ device.parameters?.filter((p: any) => p && typeof p.name === 'string' && p.value != null).length || 0 }})</h2>
|
|
<span class="chevron" :class="{ open: isSectionOpen('parameters') }">▾</span>
|
|
</div>
|
|
<div v-if="isSectionOpen('parameters')" class="section-body">
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-hover mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>{{ t('labels.name') }}</th>
|
|
<th>{{ t('labels.value') }}</th>
|
|
<th>{{ t('labels.type') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="param in device.parameters?.filter((p: any) => p && typeof p.name === 'string' && p.value != null)" :key="param.name">
|
|
<td class="multi-line small" v-html="formatName(param.name)"></td>
|
|
<td class="multi-line small font-monospace" v-html="formatName(param.value)"></td>
|
|
<td class="small">{{ param.type }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Section: Actions -->
|
|
<div class="card border-0 shadow-sm mb-3" id="actions">
|
|
<div class="card-body p-4">
|
|
<h2 class="h6 fw-bold mb-3">Actions</h2>
|
|
<div class="d-flex gap-2 flex-wrap">
|
|
<button
|
|
v-if="hasConnectionRequestUrl"
|
|
class="btn btn-outline-dark btn-sm"
|
|
@click="refreshDevice"
|
|
:disabled="isLoading"
|
|
>
|
|
{{ t('button.refresh') }}
|
|
</button>
|
|
<span
|
|
v-else
|
|
class="d-inline-block"
|
|
tabindex="0"
|
|
data-bs-toggle="tooltip"
|
|
:title="t('tooltip.refreshUnavailable', 'ConnectionRequestURL non disponible sur cet équipement')"
|
|
>
|
|
<button class="btn btn-outline-dark btn-sm" disabled>
|
|
{{ t('button.refresh') }}
|
|
</button>
|
|
</span>
|
|
<button class="btn btn-outline-dark btn-sm" @click="openGetModelPopup" :disabled="isLoading">
|
|
{{ t('button.getModel') }}
|
|
</button>
|
|
<button
|
|
class="btn btn-sm"
|
|
:class="isTraced ? 'btn-warning' : 'btn-outline-secondary'"
|
|
:disabled="tracingAction"
|
|
@click="toggleTrace"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M13.5 1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3M11 2.5a2.5 2.5 0 1 1 .603 1.628l-6.718 3.12a2.499 2.499 0 0 1 0 1.504l6.718 3.12a2.5 2.5 0 1 1-.488.876l-6.718-3.12a2.5 2.5 0 1 1 0-3.256l6.718-3.12A2.5 2.5 0 0 1 11 2.5"/>
|
|
</svg>
|
|
{{ tracingAction ? '…' : isTraced ? t('device.tracingActive') : t('device.enableTracing') }}
|
|
</button>
|
|
<Link
|
|
:href="`/tr069-trace?device=${encodeURIComponent(device.identifier)}`"
|
|
class="btn btn-outline-primary btn-sm"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M10.5 8a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0"/>
|
|
<path d="M0 8s3-5.5 8-5.5S16 8 16 8s-3 5.5-8 5.5S0 8 0 8m8 3.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7"/>
|
|
</svg>
|
|
{{ t('device.exchanges') }}
|
|
</Link>
|
|
|
|
<button class="btn btn-outline-danger btn-sm ms-auto" @click="showDeleteConfirm = true">
|
|
{{ t('device.deleteDevice') }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Confirmation suppression -->
|
|
<div v-if="showDeleteConfirm" class="alert alert-danger d-flex align-items-center gap-3 mt-3 mb-0">
|
|
<span>{{ t('device.confirmDeleteMessage') }} <strong>{{ device.identifier }}</strong> ?</span>
|
|
<button class="btn btn-danger btn-sm" @click="deleteDevice">Confirmer</button>
|
|
<button class="btn btn-outline-secondary btn-sm" @click="showDeleteConfirm = false">Annuler</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Section: Commands for this device -->
|
|
<div class="accordion-section mb-3" id="commands">
|
|
<div class="section-header" @click="toggleSection('commands')">
|
|
<h2 class="h6 mb-0">{{ t('titles.commands') }} ({{ commands?.length || 0 }})</h2>
|
|
<span class="chevron" :class="{ open: isSectionOpen('commands') }">▾</span>
|
|
</div>
|
|
<div v-if="isSectionOpen('commands')" class="section-body">
|
|
<div v-if="!commands || commands.length === 0" class="text-muted small">
|
|
Aucune commande pour cet équipement.
|
|
</div>
|
|
<div v-else class="table-responsive">
|
|
<table class="table table-sm table-hover mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>{{ t('labels.action') }}</th>
|
|
<th>{{ t('labels.status') }}</th>
|
|
<th>{{ t('labels.mode') }}</th>
|
|
<th>{{ t('labels.creationDate') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="cmd in commands" :key="cmd._id">
|
|
<td class="small">{{ cmd.action }}</td>
|
|
<td>
|
|
<span class="badge" :class="{
|
|
'bg-success': cmd.status === 'COMPLETED',
|
|
'bg-danger': cmd.status === 'FAILED',
|
|
'bg-warning text-dark': cmd.status === 'PENDING',
|
|
'bg-info': cmd.status === 'SENT',
|
|
'bg-secondary': cmd.status === 'WAITING',
|
|
}">{{ cmd.status }}</span>
|
|
</td>
|
|
<td class="small">{{ cmd.mode }}</td>
|
|
<td class="small">{{ cmd.createdAt ? new Date(cmd.createdAt).toLocaleString() : '' }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="mt-3">
|
|
<Link :href="`/commands?device=${encodeURIComponent(device.identifier)}`" class="btn btn-outline-dark btn-sm">
|
|
Voir tout
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Get Model Modal -->
|
|
<teleport to="body">
|
|
<div v-if="showGetModelPopup" class="modal-backdrop fade show"></div>
|
|
<div v-if="showGetModelPopup" class="modal fade show d-block" tabindex="-1" aria-modal="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">{{ t('modal.getModel') }}</h5>
|
|
<button type="button" class="btn-close" @click="showGetModelPopup = false"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<select v-if="modelPaths.length > 0" class="form-select" v-model="getModelInput">
|
|
<option value="" disabled>{{ t('placeholder.modelPath') }}</option>
|
|
<option v-for="path in modelPaths" :key="path" :value="path">{{ path }}</option>
|
|
</select>
|
|
<input v-else type="text" class="form-control" v-model="getModelInput" :placeholder="t('placeholder.modelPath')" />
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-secondary" @click="showGetModelPopup = false" :disabled="isLoading">{{ t('button.cancel') }}</button>
|
|
<button class="btn btn-primary" @click="submitGetModel" :disabled="isLoading">{{ t('button.send') }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.sidenav .nav-link {
|
|
color: #666;
|
|
padding: 0.4rem 0.75rem;
|
|
font-size: 0.875rem;
|
|
border-left: 3px solid transparent;
|
|
}
|
|
|
|
.sidenav .nav-link.active {
|
|
color: #000;
|
|
font-weight: 600;
|
|
border-left-color: #ff7900;
|
|
}
|
|
|
|
.accordion-section {
|
|
background: #fff;
|
|
border-radius: 0.375rem;
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.section-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 1rem 1.5rem;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
|
|
.section-header:hover {
|
|
background-color: #f8f9fa;
|
|
}
|
|
|
|
.section-body {
|
|
padding: 0 1.5rem 1.5rem;
|
|
}
|
|
|
|
.chevron {
|
|
transition: transform 0.2s;
|
|
font-size: 1.2rem;
|
|
color: #666;
|
|
}
|
|
|
|
.chevron.open {
|
|
transform: rotate(180deg);
|
|
}
|
|
|
|
td.multi-line {
|
|
white-space: normal;
|
|
word-wrap: break-word;
|
|
max-width: 400px;
|
|
}
|
|
</style>
|