Files
adam-acs/inertia/pages/DeviceDetail.vue
T

536 lines
25 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'
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)
// Check if device is already traced
async function checkTraceStatus() {
try {
const response = await fetch('/api/v1/tr069-trace')
if (response.ok) {
const data = await response.json()
const entries = data.entries || []
isTraced.value = entries.some((e: any) => e.deviceId === device.identifier)
}
} catch {
// Silently ignore
}
}
async function toggleTrace() {
tracingAction.value = true
try {
if (isTraced.value) {
const response = await fetch(`/api/v1/tr069-trace/${encodeURIComponent(device.identifier)}`, {
method: 'DELETE',
})
if (response.ok || response.status === 204) {
isTraced.value = false
}
} else {
const response = await fetch('/api/v1/tr069-trace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId: device.identifier }),
})
if (response.ok) {
isTraced.value = true
}
}
} catch {
// Silently ignore
} finally {
tracingAction.value = false
}
}
checkTraceStatus()
function deleteDevice() {
router.delete(`/devices/${encodeURIComponent(device.identifier)}`, {
onSuccess: () => router.visit('/devices'),
})
}
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 },
})
}
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 ?? ''
}
</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">Identité</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">Identifiant</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">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">Système</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 : dates côte à côte -->
<div class="row g-2">
<!-- Zone 4 : Créé le -->
<div class="col-sm-6">
<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-6">
<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>
</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) => 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.name != null && 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.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.name != null && 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 class="btn btn-outline-dark btn-sm" @click="refreshDevice" :disabled="isLoading">
{{ t('button.refresh') }}
</button>
<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-primary'"
:disabled="tracingAction"
@click="toggleTrace"
>
<svg v-if="!isTraced" xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293z"/>
</svg>
{{ tracingAction ? '…' : (isTraced ? 'Remove TR-069 monitoring' : 'Add to TR-069 monitoring') }}
</button>
<button class="btn btn-outline-danger btn-sm ms-auto" @click="showDeleteConfirm = true">
Supprimer l'équipement
</button>
</div>
<!-- Confirmation suppression -->
<div v-if="showDeleteConfirm" class="alert alert-danger d-flex align-items-center gap-3 mt-3 mb-0">
<span>Confirmer la suppression de <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>