Lane 3 (durcissement) : OnuActionsPanel (suspendre/rétablir/redémarrer/vitesse/remplacer/retirer — écritures réseau live,
suspendre coupe le client) était monté SANS permission dans EquipmentDetail. Désormais gaté can('manage_settings') + sorti
du bloc olt_name (garde propre serial+olt_ip → visible même sans olt_name). Bouton « Supprimer cet équipement » aussi gaté.
(Panel déjà monté depuis un travail antérieur ; ici = gating + placement.)
Lane 1c (déployé via bundle, PlanificationPage co-édité non commité) : puces géofence live En route/Arrivé/Reparti sur les
cartes Jour (kanban) + lignes éditeur de journée, via l'endpoint /roster/geofence-states existant mais jamais appelé
(geofenceStates + refreshGeofenceStates hooké dans reloadOccupancy). Lane 1a : le picker lead exclut déjà le tech assigné
+ garde 409 « déjà assigné dans F » → re-pick déjà empêché (aucun code risqué ajouté).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
984 lines
55 KiB
Vue
984 lines
55 KiB
Vue
<template>
|
||
<!-- Vue appareil moderne : carte réseau + voyants + tuiles + trafic + événements (le dense est replié plus bas) -->
|
||
<div v-if="device" class="topo-summary">
|
||
<div class="topo-row">
|
||
<div class="topo-node"><q-icon name="public" size="22px" :color="status.online ? 'green-6' : 'grey-5'" /><div class="topo-cap">Internet</div></div>
|
||
<div class="topo-link" :class="{ ok: status.online }"></div>
|
||
<div class="topo-node"><q-icon name="cell_tower" size="22px" color="blue-grey-6" /><div class="topo-cap">OLT</div><div class="topo-sub">{{ oltLabel }}</div></div>
|
||
<div class="topo-link" :class="{ ok: status.online }"></div>
|
||
<div class="topo-node"><div class="topo-modem"><q-icon name="router" size="22px" color="primary" /><span class="topo-dot" :class="status.online ? 'on' : 'off'"></span></div><div class="topo-cap">Modem</div><div class="topo-sub" v-if="doc.olt_slot != null">{{ doc.olt_slot }}/{{ doc.olt_port }}/{{ doc.olt_ontid }}</div></div>
|
||
<div class="topo-link" :class="{ ok: clientCount > 0 }"></div>
|
||
<div class="topo-node topo-click" @click="openHosts"><div style="position:relative;display:inline-block;"><q-icon name="devices" size="22px" color="primary" /><q-circular-progress v-if="hostsLoading" indeterminate size="15px" :thickness="0.22" color="green-5" style="position:absolute;right:-9px;top:-4px;" /></div><div class="topo-cap">{{ clientCount }} app.</div></div>
|
||
</div>
|
||
<div class="topo-badges">
|
||
<q-badge :color="statusBadgeColor">{{ status.label }}</q-badge>
|
||
<q-badge v-if="device.opticalStatus" outline :color="device.opticalStatus === 'Up' ? 'green' : 'red'">Fibre {{ device.opticalStatus }}</q-badge>
|
||
<q-badge v-if="signalLabel" outline :color="signalBadgeColor">Signal {{ signalLabel }}</q-badge>
|
||
<span v-if="device.lastInform" class="text-caption text-grey-5">{{ timeAgo(device.lastInform) }}</span>
|
||
<q-space />
|
||
<q-btn flat dense size="sm" icon="sync" :loading="refreshing" @click="doRefresh" title="Rafraîchir" />
|
||
</div>
|
||
|
||
<div class="dv-grid">
|
||
<div class="dv-tile" v-if="device.rxPower != null && device.rxPower !== 0"><div class="dv-tl"><q-icon name="network_check" size="14px" /> Signal Rx/Tx</div><div class="dv-tv" :style="{ color: rxColor }">{{ device.rxPower }}<span v-if="device.txPower != null"> / {{ device.txPower }}</span> dBm</div></div>
|
||
<div class="dv-tile" v-if="device.uptime"><div class="dv-tl"><q-icon name="schedule" size="14px" /> Uptime</div><div class="dv-tv">{{ formatUptime(device.uptime) }}</div></div>
|
||
<div class="dv-tile" v-if="managementIp"><div class="dv-tl"><q-icon name="settings" size="14px" /> Gestion</div><a :href="'https://' + managementIp + '/'" target="_blank" class="dv-tv dv-link">{{ managementIp }}</a></div>
|
||
<div class="dv-tile" v-if="lastOutage"><div class="dv-tl dv-warn"><q-icon name="power_off" size="14px" /> Dern. coupure</div><div class="dv-tv">{{ lastOutage }}</div></div>
|
||
</div>
|
||
|
||
<div class="dv-row2">
|
||
<div class="dv-wan">
|
||
<div class="dv-tl">
|
||
<q-icon name="show_chart" size="14px" /> {{ wanLive ? 'Débit WAN en direct' : 'Disponibilité & signal récents' }}
|
||
<q-btn dense flat round size="9px" class="dv-wan-btn" :icon="wanLive ? 'stop' : 'speed'" :color="wanLive ? 'red' : 'primary'" @click="toggleWanLive">
|
||
<q-tooltip>{{ wanLive ? 'Arrêter la mesure' : 'Débit WAN en direct — compteurs OLT (façon F)' }}</q-tooltip>
|
||
</q-btn>
|
||
</div>
|
||
<!-- Mode LIVE : débit WAN temps réel (compteurs Counter64 de l'OLT, échantillon 5 s, façon F) -->
|
||
<template v-if="wanLive">
|
||
<div v-if="wanLiveErr" class="dv-wan-ph">{{ wanErrMsg }}</div>
|
||
<template v-else>
|
||
<svg viewBox="0 0 220 40" class="wan-spark" preserveAspectRatio="none">
|
||
<polyline v-if="wanRates.length > 1" :points="wanLine('dl')" fill="none" stroke="#16a34a" stroke-width="1.5" />
|
||
<polyline v-if="wanRates.length > 1" :points="wanLine('ul')" fill="none" stroke="#2563eb" stroke-width="1.5" />
|
||
</svg>
|
||
<div class="text-caption q-mt-xs">
|
||
<span style="color:#16a34a;font-weight:600">▼ {{ fmtBps(wanNow && wanNow.dl) }}</span>
|
||
<span style="color:#2563eb;font-weight:600">▲ {{ fmtBps(wanNow && wanNow.ul) }}</span>
|
||
<q-spinner size="11px" class="q-ml-xs" v-if="!wanNow" /><span class="text-grey-5" v-if="!wanNow"> mesure…</span>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
<!-- Mode défaut : bande disponibilité + signal Rx (historique device_cache, passif) -->
|
||
<template v-else-if="wanHistory.length">
|
||
<div class="wan-strip">
|
||
<span v-for="(p, i) in wanHistory" :key="i" class="wan-tick" :style="{ background: p.online ? '#16a34a' : '#dc2626' }"><q-tooltip>{{ fmtTs(p.ts) }} — {{ p.online ? 'en ligne' : 'hors ligne' }}{{ p.rxPower != null ? ' · ' + p.rxPower + ' dBm' : '' }}</q-tooltip></span>
|
||
</div>
|
||
<div class="text-caption text-grey-6 q-mt-xs">Dispo {{ uptimePct }}% · Rx {{ lastRx }} · {{ wanHistory.length }} pts</div>
|
||
</template>
|
||
<div v-else class="dv-wan-ph"><q-icon name="insights" size="18px" color="grey-4" /> historique en accumulation</div>
|
||
</div>
|
||
<div class="dv-events" v-if="events.length">
|
||
<div class="dv-tl"><q-icon name="history" size="14px" /> Événements récents</div>
|
||
<div v-for="e in events.slice(0, 4)" :key="e.id" class="dv-ev"><q-icon :name="evIcon(e)" size="13px" :color="evColor(e)" /> <span class="text-grey-6">{{ evTime(e) }}</span> · {{ evLabel(e) }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Identité de l'appareil (repliée par défaut) -->
|
||
<div class="collapse-head" @click="showIdentity = !showIdentity"><q-icon :name="showIdentity ? 'expand_more' : 'chevron_right'" size="18px" class="q-mr-xs" />Identité de l'appareil</div>
|
||
<div v-show="showIdentity || !device" class="modal-field-grid">
|
||
<div v-for="f in equipFields" :key="f.field" class="mf">
|
||
<span class="mf-label">{{ f.label }}</span>
|
||
<InlineField :value="doc[f.field]" :field="f.field" doctype="Service Equipment" :docname="docName"
|
||
v-bind="f.props || {}" :placeholder="f.placeholder || '—'" @saved="v => doc[f.field] = v.value" />
|
||
</div>
|
||
<div class="mf" v-if="doc.barcode"><span class="mf-label">Code-barres</span><code>{{ doc.barcode }}</code></div>
|
||
</div>
|
||
|
||
<div v-if="device" class="q-mt-md">
|
||
<div class="collapse-head" @click="showTech = !showTech"><q-icon :name="showTech ? 'expand_more' : 'chevron_right'" size="18px" class="q-mr-xs" />Détails techniques<span class="text-caption text-grey-5 q-ml-xs">fibre · IP · WiFi · Ethernet · firmware</span></div>
|
||
<div v-show="showTech">
|
||
<div class="diag-section fibre-top">
|
||
<q-icon name="settings_input_hdmi" size="16px" class="q-mr-xs" />
|
||
<span class="text-weight-bold q-mr-sm">Fibre</span>
|
||
<q-badge :color="device.opticalStatus === 'Up' ? 'green' : 'red'" class="q-mr-sm">{{ device.opticalStatus || '—' }}</q-badge>
|
||
<template v-if="device.rxPower != null && device.rxPower !== 0">
|
||
<span class="diag-label q-mr-xs">Rx</span>
|
||
<span class="text-weight-medium" :style="{ color: rxColor }">{{ device.rxPower }} dBm</span>
|
||
</template>
|
||
<template v-if="device.txPower != null && device.txPower !== 0">
|
||
<span class="diag-label q-ml-sm q-mr-xs">Tx</span>
|
||
<span class="text-weight-medium">{{ device.txPower }} dBm</span>
|
||
</template>
|
||
</div>
|
||
|
||
<div class="diag-section" v-if="device.interfaces?.length">
|
||
<div class="diag-section-title"><q-icon name="language" size="16px" class="q-mr-xs" />Adresses IP</div>
|
||
<div class="ip-grid">
|
||
<div v-for="iface in sortedInterfaces" :key="iface.iface + iface.ip" class="ip-item">
|
||
<span class="ip-role" :class="'ip-role--' + iface.role">{{ roleLabel(iface.role) }}</span>
|
||
<a v-if="iface.role === 'management'" :href="'https://' + iface.ip + '/superadmin/'" target="_blank" class="ip-addr ip-link">{{ iface.ip }}</a>
|
||
<code v-else class="ip-addr">{{ iface.ip }}</code>
|
||
<span class="text-caption text-grey-5" v-if="iface.name">({{ iface.name }})</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="diag-section" v-if="device.wifi">
|
||
<div class="diag-section-title"><q-icon name="wifi" size="16px" class="q-mr-xs" />WiFi</div>
|
||
<div class="diag-grid">
|
||
<template v-for="(radio, key) in wifiRadios" :key="key">
|
||
<div class="diag-item" v-if="radio.status">
|
||
<span class="diag-label">{{ radio.label }}</span>
|
||
<span :class="radio.status === 'Up' ? 'text-positive' : 'text-negative'">
|
||
{{ radio.status }} · Ch {{ radio.channel || '?' }}
|
||
<template v-if="radio.bandwidth"> · {{ radio.bandwidth }}</template>
|
||
</span>
|
||
</div>
|
||
<div class="diag-item" v-if="radio.status">
|
||
<span class="diag-label">{{ radio.label }} clients</span>
|
||
<span class="text-weight-medium">{{ radio.clients }}</span>
|
||
</div>
|
||
</template>
|
||
<div class="diag-item" v-if="device.wifi.totalClients > 0">
|
||
<span class="diag-label">Total clients</span>
|
||
<span class="text-weight-bold">
|
||
{{ device.wifi.totalClients }}
|
||
<span v-if="device.wifi.meshClients > 0" class="text-caption text-grey-6">
|
||
({{ device.wifi.directClients }} direct + {{ device.wifi.meshClients }} mesh)
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="diag-section" v-if="device.mesh?.length > 1 && !hosts">
|
||
<div class="diag-section-title"><q-icon name="hub" size="16px" class="q-mr-xs" />EasyMesh ({{ device.mesh.length }} noeuds)</div>
|
||
<div class="mesh-nodes">
|
||
<div v-for="node in device.mesh" :key="node.id" class="mesh-node">
|
||
<div class="mesh-node-header">
|
||
<q-badge :color="node.active ? 'green' : 'red'" class="q-mr-xs" style="padding: 2px 5px;" />
|
||
<span class="text-weight-medium">{{ node.name || 'Node ' + node.id }}</span>
|
||
<span class="text-caption text-grey-6 q-ml-xs">{{ node.clients }} clients</span>
|
||
</div>
|
||
<div class="text-caption text-grey-5" v-if="node.ip || node.mac">
|
||
<span v-if="node.ip">{{ node.ip }}</span>
|
||
<span v-if="node.mac"> · {{ node.mac }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="diag-section" v-if="device.ethernet">
|
||
<div class="diag-section-title"><q-icon name="cable" size="16px" class="q-mr-xs" />Ethernet</div>
|
||
<div class="diag-grid">
|
||
<div class="diag-item" v-for="(port, key) in ethernetPorts" :key="key">
|
||
<span class="diag-label">{{ port.label }}</span>
|
||
<span :class="port.status === 'Up' ? 'text-positive' : 'text-grey-6'">
|
||
{{ port.status || '—' }}
|
||
<template v-if="port.speed > 0"> · {{ port.speed }} Mbps</template>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="diag-section">
|
||
<div class="diag-grid">
|
||
<div class="diag-item" v-if="device.firmware"><span class="diag-label">Firmware ACS</span><code class="text-caption">{{ device.firmware }}</code></div>
|
||
<div class="diag-item" v-if="device.ssid"><span class="diag-label">SSID</span>{{ device.ssid }}</div>
|
||
<div class="diag-item" v-if="device.uptime"><span class="diag-label">Uptime</span>{{ formatUptime(device.uptime) }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="diag-section">
|
||
<div class="diag-section-title collapse-head" @click="showHosts = !showHosts">
|
||
<q-icon :name="showHosts ? 'expand_more' : 'chevron_right'" size="18px" class="q-mr-xs" />
|
||
<q-icon name="devices" size="16px" class="q-mr-xs" />
|
||
Appareils connectés
|
||
<span v-if="hosts" class="text-caption text-grey-5 q-ml-xs">({{ hosts.total }})</span>
|
||
<q-btn flat dense size="sm" icon="refresh" class="q-ml-sm" :loading="hostsLoading" @click.stop="loadHosts(true)" title="Rafraichir la liste" />
|
||
</div>
|
||
<div v-show="showHosts">
|
||
<div v-if="hostsLoading" class="text-caption text-grey-5 q-py-xs">
|
||
<q-spinner size="14px" class="q-mr-xs" /> Interrogation de l'equipement...
|
||
</div>
|
||
<div v-else-if="hosts?.hosts.length">
|
||
<div v-for="group in groupedHosts" :key="group.nodeId" class="node-group">
|
||
<div class="node-group-header" @click="toggleNode(group.nodeId)">
|
||
<q-icon :name="expandedNodes[group.nodeId] ? 'expand_more' : 'chevron_right'" size="18px" class="node-chevron" />
|
||
<q-badge :color="group.active ? 'green' : 'red'" class="q-mr-xs" style="padding: 2px 5px;" />
|
||
<q-icon :name="group.nodeId === '_wired' ? 'cable' : 'router'" size="14px" class="q-mr-xs" :color="group.nodeId === '_wired' ? 'grey-6' : 'blue-6'" />
|
||
<span class="text-weight-medium">{{ group.label }}</span>
|
||
<q-badge :color="group.active ? 'blue-1' : 'grey-3'" :text-color="group.active ? 'blue-8' : 'grey-6'" class="q-ml-xs" style="font-size:0.7rem;">{{ group.clients.length }}</q-badge>
|
||
<span class="text-caption text-grey-5 q-ml-auto" v-if="group.ip">{{ group.ip }}</span>
|
||
<span class="text-caption text-grey-5 q-ml-xs" v-if="group.mac">· {{ group.mac }}</span>
|
||
</div>
|
||
<div v-show="expandedNodes[group.nodeId]">
|
||
<table class="hosts-table">
|
||
<thead><tr><th>Signal</th><th>Nom</th><th>IP</th><th>MAC</th><th>Connexion</th><th>Bail</th></tr></thead>
|
||
<tbody>
|
||
<tr v-for="h in group.clients" :key="h.id" :class="{ 'text-grey-5': !h.active }">
|
||
<td class="text-caption">
|
||
<span v-if="h.signal != null" :style="{ color: signalColor(h.signal) }">
|
||
<q-icon name="signal_cellular_alt" size="12px" :style="{ color: signalColor(h.signal) }" />
|
||
{{ formatSignal(h.signal) }}
|
||
</span>
|
||
<span v-else>—</span>
|
||
</td>
|
||
<td class="text-weight-medium">{{ h.name || '—' }}</td>
|
||
<td><code class="text-caption">{{ h.ip || '—' }}</code></td>
|
||
<td><code class="text-caption">{{ h.mac || '—' }}</code></td>
|
||
<td class="text-caption">{{ h.band || h.connType || '—' }}</td>
|
||
<td class="text-caption">{{ h.leaseRemaining != null ? formatLease(h.leaseRemaining) : '—' }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="hosts" class="text-caption text-grey-5 q-py-xs">Aucun appareil</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="doc.serial_number && !device" class="q-mt-md text-caption text-grey-5">
|
||
<q-spinner size="14px" class="q-mr-xs" v-if="deviceLoading" /> Chargement diagnostic ACS...
|
||
</div>
|
||
|
||
<!-- ── Diagnostic modem avancé (modem-bridge / Playwright) — DÉSACTIVÉ « pour le moment » : trop lourd (~15 s).
|
||
Réactiver : remettre v-if="managementIp || props.doc?.serial_number". Le diagnostic GenieACS/OLT + liste d'appareils suffisent. ── -->
|
||
<div v-if="false" class="q-mt-md">
|
||
<div class="info-block-title">
|
||
<q-icon name="wifi_find" size="16px" class="q-mr-xs" />
|
||
Diagnostic modem
|
||
<q-badge v-if="wifiDiag?.modemType" :label="modemTypeLabel(wifiDiag.modemType)" class="q-ml-xs" :color="modemTypeColor(wifiDiag.modemType)" dense />
|
||
<q-btn flat dense size="sm" :icon="wifiDiag ? 'refresh' : 'play_arrow'" class="q-ml-sm"
|
||
:label="wifiDiag ? 'Relancer' : 'Diagnostic'" :loading="wifiDiagLoading"
|
||
@click="runWifiDiagnostic" color="primary" />
|
||
<span v-if="wifiDiag" class="text-caption text-grey-5 q-ml-sm">{{ wifiDiag.durationMs }}ms</span>
|
||
</div>
|
||
|
||
<div v-if="wifiDiagLoading && !wifiDiag" class="text-caption text-grey-5 q-py-sm">
|
||
<q-spinner size="14px" class="q-mr-xs" /> Connexion au modem via Playwright (~15s)...
|
||
</div>
|
||
<div v-if="wifiDiagError" class="text-caption text-negative q-py-xs">{{ wifiDiagError }}</div>
|
||
|
||
<template v-if="wifiDiag">
|
||
<!-- Wired equipment detection banner -->
|
||
<div v-if="wifiDiag.wiredEquipment?.length" class="adv-issue adv-issue--info q-mb-sm">
|
||
<div class="adv-issue-header">
|
||
<q-icon name="router" size="16px" />
|
||
<span class="text-weight-bold">{{ wifiDiag.wiredEquipment.length }} repeteur(s) mesh detecte(s)</span>
|
||
</div>
|
||
<div v-for="eq in wifiDiag.wiredEquipment" :key="eq.mac" class="q-ml-md q-mt-xs text-caption">
|
||
<q-icon name="router" size="12px" class="q-mr-xs" color="info" />
|
||
<span class="text-weight-medium">{{ eq.hostname || eq.model }}</span>
|
||
— {{ eq.ip }} ({{ eq.mac }})
|
||
<q-badge :color="eq.type === 'mesh_repeater' ? 'blue-2' : 'orange-2'" :text-color="eq.type === 'mesh_repeater' ? 'blue-9' : 'orange-9'" class="q-ml-xs" style="font-size:0.6rem;">
|
||
{{ eq.type === 'mesh_repeater' ? 'Confirme' : 'Probable' }}
|
||
</q-badge>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Ethernet ports (enhanced) -->
|
||
<div v-if="wifiDiag.ethernetPorts?.length" class="diag-section">
|
||
<div class="diag-section-title"><q-icon name="cable" size="16px" class="q-mr-xs" />Ports Ethernet</div>
|
||
<div class="diag-grid">
|
||
<div v-for="port in wifiDiag.ethernetPorts" :key="port.port" class="diag-item">
|
||
<span class="diag-label">{{ port.label }}</span>
|
||
<span :class="port.status === 'Up' ? 'text-positive' : 'text-grey-6'">
|
||
{{ port.status || '—' }}
|
||
<template v-if="port.speed > 0"> · {{ port.speed }} Mbps</template>
|
||
<template v-if="port.stats && port.status === 'Up'">
|
||
· {{ formatBytes(port.stats.txBytes) }}↑ {{ formatBytes(port.stats.rxBytes) }}↓
|
||
</template>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- DHCP Leases -->
|
||
<div v-if="wifiDiag.dhcpLeases?.length" class="diag-section">
|
||
<div class="diag-section-title">
|
||
<q-icon name="dns" size="16px" class="q-mr-xs" />
|
||
Baux DHCP ({{ wifiDiag.dhcpLeases.length }})
|
||
</div>
|
||
<table class="hosts-table">
|
||
<thead><tr><th>Nom</th><th>IP</th><th>MAC</th><th>Bail</th><th>Type</th></tr></thead>
|
||
<tbody>
|
||
<tr v-for="l in wifiDiag.dhcpLeases" :key="l.mac">
|
||
<td class="text-weight-medium">{{ l.hostname || '—' }}</td>
|
||
<td><code class="text-caption">{{ l.ip }}</code></td>
|
||
<td><code class="text-caption">{{ l.mac }}</code></td>
|
||
<td class="text-caption">{{ l.expiry != null ? formatLease(l.expiry) : '—' }}</td>
|
||
<td>
|
||
<q-badge v-if="l.isMeshRepeater" color="blue-1" text-color="blue-9" style="font-size:0.6rem;">Mesh</q-badge>
|
||
<q-badge v-else-if="l.isWired" color="grey-3" text-color="grey-8" style="font-size:0.6rem;">Filaire</q-badge>
|
||
<q-badge v-else color="green-2" text-color="green-9" style="font-size:0.6rem;">WiFi</q-badge>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- Issues panel -->
|
||
<div v-if="wifiDiag.issues.length" class="adv-issues q-mb-sm">
|
||
<div v-for="(issue, i) in wifiDiag.issues" :key="i" class="adv-issue" :class="'adv-issue--' + issue.severity">
|
||
<div class="adv-issue-header">
|
||
<q-icon :name="issue.severity === 'critical' ? 'error' : issue.severity === 'warning' ? 'warning' : 'info'" size="16px" />
|
||
<span class="text-weight-bold">{{ issue.message }}</span>
|
||
</div>
|
||
<div class="adv-issue-detail">{{ issue.detail }}</div>
|
||
<div class="adv-issue-action"><q-icon name="lightbulb" size="12px" class="q-mr-xs" />{{ issue.action }}</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="adv-ok q-mb-sm">
|
||
<q-icon name="check_circle" size="16px" color="positive" class="q-mr-xs" />
|
||
Aucun probleme detecte — reseau en bon etat.
|
||
</div>
|
||
|
||
<!-- WAN IPs -->
|
||
<div v-if="wifiDiag.wanIPs.length" class="diag-section">
|
||
<div class="diag-section-title"><q-icon name="public" size="16px" class="q-mr-xs" />Adresses WAN (ONU)</div>
|
||
<div class="ip-grid">
|
||
<div v-for="w in wifiDiag.wanIPs" :key="w.ip" class="ip-item">
|
||
<span class="ip-role" :class="'ip-role--' + w.role">{{ wanRoleLabel(w.role) }}</span>
|
||
<code class="ip-addr">{{ w.ip }}</code>
|
||
<span class="text-caption text-grey-5">/{{ maskToCidr(w.mask) }} {{ w.type }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mesh topology -->
|
||
<div v-if="wifiDiag.meshNodes.length > 1" class="diag-section">
|
||
<div class="diag-section-title"><q-icon name="hub" size="16px" class="q-mr-xs" />Topologie Mesh ({{ wifiDiag.meshNodes.length }} noeuds)</div>
|
||
<div class="adv-mesh-nodes">
|
||
<div v-for="node in wifiDiag.meshNodes" :key="node.mac" class="adv-mesh-node">
|
||
<div class="adv-mesh-header">
|
||
<q-badge :color="node.active ? 'green' : 'red'" style="padding: 2px 5px;" />
|
||
<span class="text-weight-bold q-ml-xs">{{ node.hostname }}</span>
|
||
<span class="text-caption text-grey-6 q-ml-xs">{{ node.model }}</span>
|
||
<q-badge v-if="node.isController" outline color="info" class="q-ml-xs" style="font-size:0.6rem;">Routeur</q-badge>
|
||
</div>
|
||
<div class="adv-mesh-stats">
|
||
<span v-if="!node.isController" class="adv-mesh-stat">
|
||
<span class="diag-label">Backhaul</span>
|
||
<span :style="{ color: backhaulColor(node.backhaul.signal) }">
|
||
<q-icon name="signal_cellular_alt" size="12px" /> {{ node.backhaul.signal }}/255
|
||
</span>
|
||
· {{ node.backhaul.linkRate }} Mbps
|
||
· {{ node.backhaul.utilization }}% util.
|
||
</span>
|
||
<span class="adv-mesh-stat"><span class="diag-label">CPU</span> {{ node.cpu }}%</span>
|
||
<span class="adv-mesh-stat"><span class="diag-label">Uptime</span> {{ formatUptime(node.uptime) }}</span>
|
||
<span v-if="node.speedDown || node.speedUp" class="adv-mesh-stat">
|
||
<span class="diag-label">Debit</span> ↓{{ node.speedDown }} ↑{{ node.speedUp }} Mbps
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Radios -->
|
||
<div v-if="wifiDiag.radios.length" class="diag-section">
|
||
<div class="diag-section-title"><q-icon name="settings_input_antenna" size="16px" class="q-mr-xs" />Radios</div>
|
||
<div class="diag-grid">
|
||
<template v-for="r in wifiDiag.radios" :key="r.band">
|
||
<div class="diag-item">
|
||
<span class="diag-label">{{ r.band }}</span>
|
||
<span :class="r.status === 'Up' ? 'text-positive' : 'text-negative'">
|
||
Ch {{ r.channel }} · {{ r.bandwidth }} · {{ r.standard }}
|
||
</span>
|
||
</div>
|
||
<div class="diag-item">
|
||
<span class="diag-label">{{ r.band }} config</span>
|
||
<span>Puissance {{ r.txPower }}% · Canal {{ r.autoChannel ? 'auto' : 'fixe' }}</span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Connected clients -->
|
||
<div v-if="wifiDiag.clients.length" class="diag-section">
|
||
<div class="diag-section-title">
|
||
<q-icon name="devices" size="16px" class="q-mr-xs" />
|
||
Clients WiFi ({{ wifiDiag.clients.filter(c => c.active).length }} connectes
|
||
<span v-if="wifiDiag.clients.some(c => !c.active)" class="text-caption text-grey-5">
|
||
/ {{ wifiDiag.clients.filter(c => !c.active).length }} hors ligne
|
||
</span>)
|
||
</div>
|
||
<table class="hosts-table adv-clients-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Signal</th>
|
||
<th>Nom</th>
|
||
<th>Noeud</th>
|
||
<th>Bande</th>
|
||
<th>Debit ↓/↑</th>
|
||
<th>Perte</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="c in wifiDiag.clients" :key="c.mac" :class="{ 'text-grey-5 adv-inactive-row': !c.active }">
|
||
<td>
|
||
<template v-if="c.active">
|
||
<div class="adv-signal-bar">
|
||
<div class="adv-signal-fill" :style="{ width: signalPercent(c.signal) + '%', background: signalColor(c.signal) }" />
|
||
</div>
|
||
<span class="text-caption" :style="{ color: signalColor(c.signal) }">{{ c.signal > 0 ? c.signal : '?' }}</span>
|
||
</template>
|
||
<q-icon v-else name="wifi_off" size="14px" color="grey-4" />
|
||
</td>
|
||
<td>
|
||
<span class="text-weight-medium">{{ c.hostname || '—' }}</span>
|
||
<div class="text-caption text-grey-5">{{ c.ip || 'hors ligne' }}</div>
|
||
</td>
|
||
<td class="text-caption">{{ c.meshNode || '—' }}</td>
|
||
<td class="text-caption">
|
||
<q-badge :color="c.band === '5GHz' ? 'green-2' : c.band === '2.4GHz' ? 'blue-2' : 'grey-3'"
|
||
:text-color="c.band === '5GHz' ? 'green-9' : c.band === '2.4GHz' ? 'blue-9' : 'grey-7'"
|
||
style="font-size: 0.65rem;">{{ c.band || c.standard || '?' }}</q-badge>
|
||
</td>
|
||
<td class="text-caption">
|
||
{{ Math.round(c.linkDown / 1000) }}/{{ Math.round(c.linkUp / 1000) }} Mbps
|
||
</td>
|
||
<td>
|
||
<span v-if="c.lossPercent > 10" class="text-negative text-weight-bold">{{ c.lossPercent }}%</span>
|
||
<span v-else-if="c.lossPercent > 5" class="text-warning text-weight-bold">{{ c.lossPercent }}%</span>
|
||
<span v-else-if="c.lossPercent > 0" class="text-caption text-grey-6">{{ c.lossPercent }}%</span>
|
||
<span v-else class="text-caption text-grey-5">—</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div v-if="doc.olt_name" class="q-mt-md">
|
||
<div class="info-block-title">Information OLT</div>
|
||
<div class="modal-field-grid">
|
||
<div class="mf"><span class="mf-label">OLT</span>{{ doc.olt_name }}</div>
|
||
<div class="mf"><span class="mf-label">IP OLT</span><code>{{ doc.olt_ip }}</code></div>
|
||
<div class="mf"><span class="mf-label">Slot / Port</span>{{ doc.olt_slot }} / {{ doc.olt_port }}</div>
|
||
<div class="mf"><span class="mf-label">ONT ID</span>{{ doc.olt_ontid }}</div>
|
||
</div>
|
||
<div class="mf" v-if="doc.installation_date"><span class="mf-label">Installe le</span>{{ doc.installation_date }}</div>
|
||
<div class="mf" v-if="doc.warranty_end"><span class="mf-label">Fin garantie</span>{{ doc.warranty_end }}</div>
|
||
<div class="mf" v-if="doc.service_location"><span class="mf-label">Adresse</span>{{ doc.service_location }}</div>
|
||
<div class="mf" v-if="doc.subscription"><span class="mf-label">Abonnement</span>{{ doc.subscription }}</div>
|
||
</div>
|
||
|
||
<!-- Actions cycle-de-vie ONU (plan→confirm→run : suspendre/rétablir/redémarrer/vitesse/remplacer/retirer) — remplace le
|
||
« Do Stuff » de F. GATÉ manage_settings (écritures réseau : suspendre coupe le client). Bloc AUTONOME (n'exige pas olt_name). -->
|
||
<div v-if="doc.serial_number && doc.olt_ip && can('manage_settings')" class="q-mt-md">
|
||
<div class="info-block-title">Actions appareil (réseau)</div>
|
||
<OnuActionsPanel :serial="doc.serial_number" :olt="doc.olt_ip" :status="doc.status" @changed="onOnuChanged" />
|
||
</div>
|
||
|
||
<!-- Port Neighbor Context — shows health of the same OLT port -->
|
||
<div v-if="portCtx && portCtx.total > 0" class="q-mt-md">
|
||
<div class="info-block-title">
|
||
<q-icon name="hub" size="16px" class="q-mr-xs" />
|
||
Voisinage port
|
||
<q-badge v-if="portCtx.is_mass_outage" color="red" class="q-ml-sm">Panne secteur</q-badge>
|
||
<q-badge v-else-if="portCtx.is_warning" color="orange" class="q-ml-sm">Alerte</q-badge>
|
||
<q-badge v-else color="green" class="q-ml-sm">Normal</q-badge>
|
||
</div>
|
||
<div class="port-ctx-bar q-mt-xs">
|
||
<div class="port-ctx-fill port-ctx-fill--online" :style="{ width: (portCtx.online / portCtx.total * 100) + '%' }" />
|
||
<div class="port-ctx-fill port-ctx-fill--offline" :style="{ width: (portCtx.offline / portCtx.total * 100) + '%' }" />
|
||
</div>
|
||
<div class="port-ctx-legend text-caption q-mt-xs">
|
||
<span class="text-positive text-weight-bold">{{ portCtx.online }}</span> en ligne
|
||
<span v-if="portCtx.offline > 0" class="q-ml-md text-negative text-weight-bold">{{ portCtx.offline }}</span>
|
||
<span v-if="portCtx.offline > 0"> hors ligne</span>
|
||
<span class="text-grey-6 q-ml-md">/ {{ portCtx.total }} sur le port</span>
|
||
</div>
|
||
<div v-if="portCtx.offline > 0" class="port-ctx-neighbors q-mt-xs">
|
||
<div v-for="n in portCtx.neighbors.filter(x => !x.is_self && x.status !== 'online')" :key="n.serial_prefix"
|
||
class="port-ctx-neighbor text-caption">
|
||
<q-icon name="circle" size="8px" color="red" class="q-mr-xs" />
|
||
{{ n.serial_prefix }} — {{ n.cause || 'inconnu' }}
|
||
<span v-if="n.distance_m" class="text-grey-5 q-ml-xs">{{ n.distance_m }}m</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="q-mt-md">
|
||
<div class="info-block-title">Acces distant</div>
|
||
<div class="info-row q-py-xs"><span class="mf-label">Utilisateur</span>
|
||
<InlineField :value="doc.login_user" field="login_user" doctype="Service Equipment" :docname="docName"
|
||
placeholder="—" @saved="v => doc.login_user = v.value" />
|
||
</div>
|
||
</div>
|
||
<div class="q-mt-md">
|
||
<div class="info-block-title">Notes</div>
|
||
<InlineField :value="doc.notes" field="notes" doctype="Service Equipment" :docname="docName"
|
||
type="textarea" placeholder="Ajouter des notes..." @saved="v => doc.notes = v.value" />
|
||
</div>
|
||
<div v-if="doc.move_log?.length" class="q-mt-md">
|
||
<div class="info-block-title">Historique ({{ doc.move_log.length }})</div>
|
||
<div v-for="m in doc.move_log" :key="m.idx" class="info-row q-py-xs text-caption">
|
||
{{ m.date }} --- {{ m.from_location || '?' }} → {{ m.to_location || '?' }} {{ m.reason ? '(' + m.reason + ')' : '' }}
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="can('manage_settings')" class="q-mt-lg q-pt-md" style="border-top: 1px solid #f1f5f9">
|
||
<q-btn flat dense no-caps color="negative" icon="delete_outline" label="Supprimer cet équipement"
|
||
@click="confirmDelete" :loading="deleting" class="text-caption" />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||
import { useQuasar } from 'quasar'
|
||
import InlineField from 'src/components/shared/InlineField.vue'
|
||
import OnuActionsPanel from './modules/OnuActionsPanel.vue'
|
||
import { usePermissions } from 'src/composables/usePermissions'
|
||
import { useDeviceStatus } from 'src/composables/useDeviceStatus'
|
||
import { useModemDiagnostic } from 'src/composables/useModemDiagnostic'
|
||
import { deleteDoc } from 'src/api/erp'
|
||
import { HUB_URL } from 'src/config/hub'
|
||
|
||
const props = defineProps({ doc: { type: Object, required: true }, docName: String })
|
||
const emit = defineEmits(['deleted'])
|
||
|
||
const $q = useQuasar()
|
||
const { can } = usePermissions() // écritures réseau ONU + suppression = gated (manage_settings)
|
||
const { fetchStatus, fetchOltStatus, fetchPortContext, getDevice, isOnline, combinedStatus, signalQuality, refreshDeviceParams, fetchHosts, loading: deviceLoading } = useDeviceStatus()
|
||
const { fetchDiagnostic, fetchDiagnosticAuto, loading: wifiDiagLoading, error: wifiDiagError, data: wifiDiag } = useModemDiagnostic()
|
||
const refreshing = ref(false)
|
||
const deleting = ref(false)
|
||
const portCtx = ref(null)
|
||
|
||
const managementIp = computed(() => {
|
||
const iface = device.value?.interfaces?.find(i => i.role === 'management')
|
||
return iface?.ip || null
|
||
})
|
||
|
||
async function runWifiDiagnostic() {
|
||
const serial = props.doc?.serial_number
|
||
// Try auto-fetch first (credentials resolved server-side)
|
||
if (serial) {
|
||
const result = await fetchDiagnosticAuto(serial)
|
||
if (result) return
|
||
}
|
||
// Fallback: manual password entry on any auto-fetch failure
|
||
const ip = managementIp.value || props.doc?.ip_address
|
||
if (!ip) return
|
||
const errMsg = wifiDiagError.value || ''
|
||
$q.dialog({
|
||
title: 'Mot de passe modem',
|
||
message: errMsg
|
||
? `Echec auto (${errMsg.substring(0, 80)}) — entrer le mot de passe pour ${ip}`
|
||
: `Entrer le mot de passe superadmin pour ${ip}`,
|
||
prompt: { model: '', type: 'password', filled: true },
|
||
cancel: { flat: true, label: 'Annuler' },
|
||
ok: { label: 'Lancer', color: 'primary' },
|
||
persistent: false,
|
||
}).onOk(pass => {
|
||
if (pass) fetchDiagnostic(ip, pass)
|
||
})
|
||
}
|
||
|
||
const MODEM_TYPE_LABELS = {
|
||
tplink_xx230v: 'TP-Link',
|
||
raisecom_boa: 'Raisecom 803-W',
|
||
raisecom_php: 'Raisecom 803-WS2',
|
||
}
|
||
const MODEM_TYPE_COLORS = {
|
||
tplink_xx230v: 'teal',
|
||
raisecom_boa: 'orange',
|
||
raisecom_php: 'deep-purple',
|
||
}
|
||
const modemTypeLabel = (type) => MODEM_TYPE_LABELS[type] || type
|
||
const modemTypeColor = (type) => MODEM_TYPE_COLORS[type] || 'grey'
|
||
|
||
const wanRoleLabel = (role) => ROLE_LABELS[role] || role
|
||
|
||
function maskToCidr(mask) {
|
||
if (!mask) return '?'
|
||
return mask.split('.').reduce((c, o) => c + (parseInt(o) >>> 0).toString(2).replace(/0/g, '').length, 0)
|
||
}
|
||
|
||
function signalPercent(raw) {
|
||
if (!raw || raw <= 0) return 0
|
||
return raw >= 0 ? Math.round((raw / 255) * 100) : Math.round(Math.max(0, Math.min(100, ((raw + 90) / 60) * 100)))
|
||
}
|
||
|
||
function backhaulColor(signal) {
|
||
if (signal >= 100) return '#4ade80'
|
||
if (signal >= 80) return '#a3e635'
|
||
if (signal >= 60) return '#fbbf24'
|
||
return '#f87171'
|
||
}
|
||
|
||
function formatBytes(bytes) {
|
||
if (!bytes || bytes <= 0) return '0 B'
|
||
const units = ['B', 'KB', 'MB', 'GB']
|
||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i]
|
||
}
|
||
|
||
function confirmDelete () {
|
||
$q.dialog({
|
||
title: 'Supprimer cet équipement ?',
|
||
message: `${props.doc.serial_number || props.docName} sera supprimé définitivement.`,
|
||
cancel: { flat: true, label: 'Annuler' },
|
||
ok: { color: 'negative', label: 'Supprimer', flat: true },
|
||
persistent: true,
|
||
}).onOk(async () => {
|
||
deleting.value = true
|
||
try {
|
||
await deleteDoc('Service Equipment', props.docName)
|
||
$q.notify({ type: 'positive', message: 'Équipement supprimé' })
|
||
emit('deleted', props.docName)
|
||
} catch (e) {
|
||
$q.notify({ type: 'negative', message: 'Erreur: ' + e.message })
|
||
} finally {
|
||
deleting.value = false
|
||
}
|
||
})
|
||
}
|
||
const hosts = ref(null)
|
||
const hostsLoading = ref(false)
|
||
const expandedNodes = ref({})
|
||
|
||
const equipFields = [
|
||
{ field: 'equipment_type', label: 'Type', props: { type: 'select', options: ['ONT', 'Router', 'Switch', 'AP', 'OLT', 'Décodeur', 'Modem', 'Autre'] } },
|
||
{ field: 'status', label: 'Statut', props: { type: 'select', options: ['Active', 'Inactive', 'En stock', 'Défectueux', 'Retourné'] } },
|
||
{ field: 'brand', label: 'Marque' },
|
||
{ field: 'model', label: 'Modele' },
|
||
{ field: 'serial_number', label: 'N serie' },
|
||
{ field: 'mac_address', label: 'MAC' },
|
||
{ field: 'ip_address', label: 'IP' },
|
||
{ field: 'firmware_version', label: 'Firmware' },
|
||
{ field: 'ownership', label: 'Propriete', props: { type: 'select', options: ['Client', 'Compagnie', 'Location'] } },
|
||
]
|
||
|
||
const toggleNode = (nodeId) => { expandedNodes.value[nodeId] = !expandedNodes.value[nodeId] }
|
||
const device = computed(() => props.doc.serial_number ? getDevice(props.doc.serial_number) : null)
|
||
const online = computed(() => isOnline(props.doc.serial_number))
|
||
const status = computed(() => combinedStatus(props.doc.serial_number))
|
||
const statusBadgeColor = computed(() => {
|
||
if (status.value.source === 'unknown') return 'amber'
|
||
return status.value.online === true ? 'green' : status.value.online === false ? 'red' : 'grey'
|
||
})
|
||
|
||
const rxColor = computed(() => {
|
||
const q = signalQuality(props.doc.serial_number)
|
||
return q === 'excellent' ? '#4ade80' : q === 'good' ? '#a3e635' : q === 'fair' ? '#fbbf24' : '#f87171'
|
||
})
|
||
|
||
const sortedInterfaces = computed(() => {
|
||
if (!device.value?.interfaces) return []
|
||
const order = { internet: 0, management: 1, service: 2, lan: 3, unknown: 4 }
|
||
return [...device.value.interfaces].sort((a, b) => (order[a.role] ?? 9) - (order[b.role] ?? 9))
|
||
})
|
||
|
||
const ROLE_LABELS = { internet: 'Internet', management: 'Gestion', service: 'Service', lan: 'LAN', unknown: '?' }
|
||
const roleLabel = (role) => ROLE_LABELS[role] || role
|
||
|
||
const wifiRadios = computed(() => {
|
||
if (!device.value?.wifi) return []
|
||
const w = device.value.wifi
|
||
const radios = []
|
||
if (w.radio1?.status) radios.push({ ...w.radio1, label: '2.4 GHz' })
|
||
if (w.radio2?.status) radios.push({ ...w.radio2, label: '5 GHz' })
|
||
if (w.radio3?.status) radios.push({ ...w.radio3, label: '6 GHz' })
|
||
return radios
|
||
})
|
||
|
||
const ethernetPorts = computed(() => {
|
||
if (!device.value?.ethernet) return []
|
||
const e = device.value.ethernet, ports = []
|
||
if (e.port1) ports.push({ ...e.port1, label: 'Port 1' })
|
||
if (e.port2) ports.push({ ...e.port2, label: 'Port 2' })
|
||
return ports
|
||
})
|
||
|
||
const groupedHosts = computed(() => {
|
||
if (!hosts.value?.hosts) return []
|
||
const meshNodes = device.value?.mesh || []
|
||
const groups = new Map()
|
||
for (const node of meshNodes) {
|
||
groups.set(node.name || node.id, { nodeId: node.name || node.id, label: node.name || 'Node ' + node.id, active: node.active, ip: node.ip, mac: node.mac, clients: [] })
|
||
}
|
||
const wiredGroup = { nodeId: '_wired', label: 'Filaire / Autre', active: true, ip: null, mac: null, clients: [] }
|
||
for (const h of hosts.value.hosts) {
|
||
if (h.isMeshNode) continue
|
||
const nodeName = h.attachedNode
|
||
if (nodeName && groups.has(nodeName)) groups.get(nodeName).clients.push(h)
|
||
else if (nodeName) groups.set(nodeName, { nodeId: nodeName, label: nodeName, active: true, ip: null, mac: null, clients: [h] })
|
||
else wiredGroup.clients.push(h)
|
||
}
|
||
const result = [...groups.values()]
|
||
if (wiredGroup.clients.length) result.push(wiredGroup)
|
||
return result
|
||
})
|
||
|
||
const SIGNAL_STEPS = [
|
||
{ pct: 0, color: 'hsl(0, 100%, 40%)' }, { pct: 10, color: 'hsl(6, 100%, 42%)' },
|
||
{ pct: 20, color: 'hsl(12, 100%, 44%)' }, { pct: 30, color: 'hsl(20, 95%, 45%)' },
|
||
{ pct: 40, color: 'hsl(30, 100%, 45%)' }, { pct: 50, color: 'hsl(42, 100%, 45%)' },
|
||
{ pct: 60, color: 'hsl(54, 100%, 40%)' }, { pct: 70, color: 'hsl(72, 90%, 38%)' },
|
||
{ pct: 80, color: 'hsl(90, 80%, 36%)' }, { pct: 90, color: 'hsl(105, 75%, 35%)' },
|
||
{ pct: 100, color: 'hsl(120, 70%, 35%)' },
|
||
]
|
||
|
||
function signalColor (raw) {
|
||
if (raw == null) return '#9ca3af'
|
||
const val = parseFloat(raw)
|
||
if (isNaN(val)) return '#9ca3af'
|
||
const pct = val >= 0 ? Math.round((val / 255) * 100) : Math.round(Math.max(0, Math.min(100, ((val + 90) / 60) * 100)))
|
||
return SIGNAL_STEPS[Math.min(10, Math.max(0, Math.floor(pct / 10)))].color
|
||
}
|
||
|
||
function formatSignal (raw) {
|
||
if (raw == null) return null
|
||
const val = parseFloat(raw)
|
||
return isNaN(val) ? null : val >= 0 ? Math.round((val / 255) * 100) + '%' : val + ' dBm'
|
||
}
|
||
|
||
function timeAgo (dateStr) {
|
||
if (!dateStr) return ''
|
||
const mins = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000)
|
||
if (mins < 1) return "a l'instant"
|
||
if (mins < 60) return `il y a ${mins}m`
|
||
const hrs = Math.floor(mins / 60)
|
||
return hrs < 24 ? `il y a ${hrs}h` : `il y a ${Math.floor(hrs / 24)}j`
|
||
}
|
||
|
||
function formatLease (seconds) {
|
||
if (seconds == null || seconds <= 0) return 'expire'
|
||
const h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60)
|
||
return h > 0 ? `${h}h${m > 0 ? m + 'm' : ''}` : `${m}m`
|
||
}
|
||
|
||
async function loadHosts (refresh = false) {
|
||
if (!props.doc.serial_number) return
|
||
hostsLoading.value = true
|
||
try {
|
||
hosts.value = await fetchHosts(props.doc.serial_number, refresh)
|
||
if (hosts.value?.hosts) {
|
||
const nodes = new Set()
|
||
for (const h of hosts.value.hosts) nodes.add(h.attachedNode || '_wired')
|
||
if (device.value?.mesh) for (const n of device.value.mesh) nodes.add(n.name || n.id)
|
||
const exp = {}; for (const id of nodes) exp[id] = true
|
||
expandedNodes.value = exp
|
||
}
|
||
} catch {}
|
||
hostsLoading.value = false
|
||
}
|
||
|
||
function formatUptime (seconds) {
|
||
if (!seconds) return '—'
|
||
const d = Math.floor(seconds / 86400), h = Math.floor((seconds % 86400) / 3600), m = Math.floor((seconds % 3600) / 60)
|
||
return d > 0 ? `${d}j ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
|
||
}
|
||
|
||
// Épuré : sections denses repliées par défaut (l'essentiel est dans le résumé en tête).
|
||
const showIdentity = ref(false)
|
||
const showTech = ref(false)
|
||
const showHosts = ref(false)
|
||
const clientCount = computed(() => (hosts.value && hosts.value.total != null) ? hosts.value.total : (device.value?.wifi?.totalClients ?? 0))
|
||
const SIG_LBL = { excellent: 'excellent', good: 'bon', fair: 'faible', bad: 'critique' }
|
||
const signalLabel = computed(() => SIG_LBL[signalQuality(props.doc.serial_number)] || '')
|
||
const signalBadgeColor = computed(() => { const s = signalQuality(props.doc.serial_number); return (s === 'excellent' || s === 'good') ? 'green' : s === 'fair' ? 'orange' : s === 'bad' ? 'red' : 'grey' })
|
||
const oltLabel = computed(() => props.doc.olt_name || props.doc.olt_ip || 'OLT')
|
||
// Événements récents (register / dying-gasp / online-offline) — depuis nos device_events (le poller les journalise déjà).
|
||
const events = ref([])
|
||
async function loadEvents () {
|
||
if (!props.doc.serial_number) return
|
||
try { const r = await fetch(`${HUB_URL}/devices/events?serial=${encodeURIComponent(props.doc.serial_number)}&limit=8`); if (r.ok) events.value = await r.json() } catch (e) { /* */ }
|
||
}
|
||
const lastOutage = computed(() => { const o = events.value.find(e => e.event === 'offline'); return o ? (o.reason || 'coupure') : null })
|
||
function evIcon (e) { return e.event === 'offline' ? 'power_off' : e.event === 'online' ? 'check_circle' : 'fiber_manual_record' }
|
||
function evColor (e) { return e.event === 'offline' ? 'orange-7' : e.event === 'online' ? 'green-6' : 'grey-5' }
|
||
function evLabel (e) { return e.event === 'offline' ? ('Hors ligne' + (e.reason ? ' · ' + e.reason : '')) : e.event === 'online' ? 'En ligne' : (e.event || '') }
|
||
function evTime (e) { try { return new Date(e.created_at).toLocaleString('fr-CA', { dateStyle: 'short', timeStyle: 'short' }) } catch (x) { return '' } }
|
||
// Graphe : disponibilité + signal Rx récents, depuis l'historique device_cache (vraie data ; le débit Mbps façon F = source ES, plus tard).
|
||
const wanHistory = ref([])
|
||
async function loadWanHistory () {
|
||
if (!props.doc.serial_number) return
|
||
try { const r = await fetch(`${HUB_URL}/devices/cache?serial=${encodeURIComponent(props.doc.serial_number)}`); if (r.ok) { const d = await r.json(); wanHistory.value = (Array.isArray(d.history) ? d.history : []).slice(-48) } } catch (e) { /* */ }
|
||
}
|
||
const uptimePct = computed(() => { const h = wanHistory.value; if (!h.length) return null; return Math.round(h.filter(p => p.online).length / h.length * 100) })
|
||
const lastRx = computed(() => { for (let i = wanHistory.value.length - 1; i >= 0; i--) { if (wanHistory.value[i].rxPower != null) return wanHistory.value[i].rxPower + ' dBm' } return '—' })
|
||
function fmtTs (ts) { try { return new Date(ts).toLocaleString('fr-CA', { dateStyle: 'short', timeStyle: 'short' }) } catch (e) { return '' } }
|
||
|
||
// Débit WAN EN DIRECT : mirroir de raisecom_rcmg_bandwith.php de F. Le hub lit les compteurs
|
||
// d'octets Counter64 de l'OLT (Raisecom/tech-2) ; ici on échantillonne toutes les 5 s et on
|
||
// calcule le débit = (Δoctets / Δs) × 8 (façon F). À la demande seulement (charge OLT).
|
||
const wanLive = ref(false)
|
||
const wanLiveErr = ref('') // '' | 'unsupported' | 'error' | autre raison
|
||
const wanRates = ref([]) // [{ dl, ul, ts }] bits/s, ~40 derniers points
|
||
let wanTimer = null
|
||
let wanPrev = null // dernier échantillon brut pour le delta
|
||
const WAN_ERR_MSG = {
|
||
unsupported_olt_type: 'Débit en direct indisponible — OLT TP-Link (tech 3). Seules les OLT Raisecom exposent ce compteur.',
|
||
onu_not_resolved: 'ONU absent du sondage OLT en ce moment (OLT hors supervision ?). Débit en direct indisponible.',
|
||
no_counter: 'Aucun compteur de débit pour cet ONU sur l’OLT.',
|
||
snmp_unavailable: 'Sonde SNMP indisponible.',
|
||
}
|
||
const wanErrMsg = computed(() => WAN_ERR_MSG[wanLiveErr.value] || (wanLiveErr.value ? 'Mesure indisponible pour le moment.' : ''))
|
||
const fmtBps = (bps) => (bps == null) ? '—' : (bps >= 1e6 ? (bps / 1e6).toFixed(1) + ' Mbps' : (bps / 1e3).toFixed(0) + ' kbps')
|
||
const wanNow = computed(() => wanRates.value.length ? wanRates.value[wanRates.value.length - 1] : null)
|
||
const wanMax = computed(() => { let m = 1; for (const r of wanRates.value) { if (r.dl > m) m = r.dl; if (r.ul > m) m = r.ul } return m })
|
||
function wanLine (key) {
|
||
const r = wanRates.value; if (r.length < 2) return ''
|
||
const max = wanMax.value, W = 220, H = 38, n = r.length
|
||
return r.map((p, i) => `${((i / (n - 1)) * W).toFixed(1)},${(H - ((p[key] || 0) / max) * H).toFixed(1)}`).join(' ')
|
||
}
|
||
async function wanPoll () {
|
||
if (!props.doc.serial_number) return
|
||
try {
|
||
const r = await fetch(`${HUB_URL}/collab/wan-rate?serial=${encodeURIComponent(props.doc.serial_number)}`)
|
||
if (!r.ok) { wanLiveErr.value = 'error'; stopWanTimer(); return }
|
||
const d = await r.json()
|
||
// Indisponible (OLT non Raisecom, ONU non résolu, pas de compteur) = état non transitoire → on affiche le motif et on ARRÊTE de sonder (pas de spinner infini).
|
||
if (!d.available) { wanLiveErr.value = d.reason || 'error'; stopWanTimer(); return }
|
||
wanLiveErr.value = ''
|
||
const cur = { ts: d.ts, rx: d.rxOctets, tx: d.txOctets }
|
||
if (wanPrev) {
|
||
const sec = (cur.ts - wanPrev.ts) / 1000
|
||
const delta = (a, b) => { if (a == null || b == null) return null; let x = b - a; if (x < 0) x = 0; return sec > 0 ? (x / sec) * 8 : null }
|
||
wanRates.value = [...wanRates.value, { dl: delta(wanPrev.rx, cur.rx), ul: delta(wanPrev.tx, cur.tx), ts: cur.ts }].slice(-40)
|
||
}
|
||
wanPrev = cur
|
||
} catch (e) { wanLiveErr.value = 'error'; stopWanTimer() }
|
||
}
|
||
function startWanLive () { wanLive.value = true; wanLiveErr.value = ''; wanRates.value = []; wanPrev = null; wanPoll(); wanTimer = setInterval(wanPoll, 3000) }
|
||
function stopWanTimer () { if (wanTimer) { clearInterval(wanTimer); wanTimer = null } }
|
||
function stopWanLive () { stopWanTimer(); wanLive.value = false; wanLiveErr.value = '' }
|
||
function toggleWanLive () { wanLive.value ? stopWanLive() : startWanLive() }
|
||
// Liste d'appareils au clic sur l'icône : on déplie → la version EN CACHE s'affiche vite (le hub sert le cache par défaut) + anneau de chargement pendant le rafraîchissement.
|
||
function openHosts () { showHosts.value = true; if (!hosts.value && device.value) loadHosts() }
|
||
|
||
// Une action ONU a modifié le réseau → refléter le nouveau statut (optimiste) + re-sonder l'appareil.
|
||
function onOnuChanged ({ action } = {}) {
|
||
const map = { suspend: 'Suspendu', unsuspend: 'Actif', activate: 'Actif', remove: 'Retiré' }
|
||
if (map[action]) props.doc.status = map[action]
|
||
setTimeout(doRefresh, 1500)
|
||
}
|
||
|
||
async function doRefresh () {
|
||
if (!props.doc.serial_number) return
|
||
refreshing.value = true
|
||
try {
|
||
await refreshDeviceParams(props.doc.serial_number)
|
||
fetchOltStatus(props.doc.serial_number)
|
||
setTimeout(() => { fetchStatus([{ serial_number: props.doc.serial_number }]); refreshing.value = false }, 3000)
|
||
} catch { refreshing.value = false }
|
||
}
|
||
|
||
onMounted(async () => {
|
||
if (props.doc.serial_number) {
|
||
fetchStatus([{ serial_number: props.doc.serial_number }])
|
||
fetchOltStatus(props.doc.serial_number)
|
||
fetchPortContext(props.doc.serial_number).then(ctx => { portCtx.value = ctx })
|
||
loadEvents()
|
||
loadWanHistory()
|
||
}
|
||
})
|
||
|
||
// Chargement PARESSEUX : on n'interroge le modem (modem-bridge) que si l'agent déplie « Appareils connectés ».
|
||
watch(showHosts, v => { if (v && !hosts.value && device.value) loadHosts() })
|
||
watch(() => props.doc.serial_number, sn => {
|
||
stopWanLive() // arrêter la mesure live de l'appareil précédent
|
||
if (sn) {
|
||
hosts.value = null
|
||
fetchStatus([{ serial_number: sn }])
|
||
fetchOltStatus(sn)
|
||
loadEvents(); loadWanHistory() // rafraîchir la bande dispo/événements pour le nouvel appareil
|
||
}
|
||
})
|
||
onUnmounted(() => stopWanLive())
|
||
</script>
|
||
|
||
<style scoped>
|
||
.acs-badge-pulse { animation: badge-pulse 1.2s ease-in-out infinite; }
|
||
@keyframes badge-pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.4; }
|
||
}
|
||
.topo-summary { border: 1px solid #e2e8f0; border-radius: 10px; padding: 12px; background: #f8fafc; margin-bottom: 10px; }
|
||
.topo-row { display: flex; align-items: flex-start; justify-content: center; gap: 0; }
|
||
.topo-node { display: flex; flex-direction: column; align-items: center; width: 90px; }
|
||
.topo-modem { position: relative; }
|
||
.topo-dot { position: absolute; right: -2px; bottom: 2px; width: 9px; height: 9px; border-radius: 999px; border: 2px solid #f8fafc; }
|
||
.topo-dot.on { background: #21BA45; } .topo-dot.off { background: #C10015; }
|
||
.topo-cap { font-size: 0.72rem; color: #64748b; margin-top: 3px; text-align: center; }
|
||
.topo-link { flex: 1; max-width: 56px; height: 2px; background: #cbd5e1; margin-top: 12px; }
|
||
.topo-link.ok { background: #21BA45; opacity: .55; }
|
||
.topo-badges { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||
.topo-sub { font-size: 0.66rem; color: #94a3b8; }
|
||
.dv-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-top: 10px; }
|
||
.dv-tile { border: 1px solid #e2e8f0; border-radius: 7px; padding: 6px 8px; background: #fff; }
|
||
.dv-tl { font-size: 0.68rem; color: #64748b; display: flex; align-items: center; gap: 3px; }
|
||
.dv-tl.dv-warn { color: #b45309; }
|
||
.dv-tv { font-size: 0.84rem; font-weight: 500; color: #1e293b; margin-top: 2px; }
|
||
.dv-link { color: #4f46e5; text-decoration: none; }
|
||
.dv-row2 { display: flex; gap: 8px; margin-top: 8px; }
|
||
.dv-wan, .dv-events { flex: 1; border: 1px solid #e2e8f0; border-radius: 8px; padding: 7px 9px; background: #fff; }
|
||
.dv-wan-ph { font-size: 0.78rem; color: #94a3b8; padding: 10px 0; text-align: center; }
|
||
.dv-ev { font-size: 0.74rem; color: #334155; padding: 2px 0; }
|
||
.topo-click { cursor: pointer; border-radius: 8px; transition: background .12s; }
|
||
.topo-click:hover { background: #eef2ff; }
|
||
.wan-strip { display: flex; gap: 2px; align-items: flex-end; height: 22px; margin-top: 4px; }
|
||
.wan-tick { flex: 1; min-width: 2px; height: 100%; border-radius: 1px; opacity: .85; }
|
||
.dv-wan-btn { float: right; margin-top: -2px; }
|
||
.wan-spark { width: 100%; height: 38px; display: block; margin-top: 4px; }
|
||
.collapse-head { display: flex; align-items: center; cursor: pointer; font-size: 0.8rem; font-weight: 600; color: #475569; padding: 6px 2px; border-top: 1px solid #f1f5f9; user-select: none; }
|
||
.collapse-head:hover { color: #1e293b; }
|
||
.diag-section { margin-top: 8px; }
|
||
.fibre-top { display: flex; align-items: center; font-size: 0.84rem; padding: 6px 8px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0; }
|
||
.diag-section-title { font-size: 0.78rem; font-weight: 600; color: #4b5563; margin-bottom: 4px; display: flex; align-items: center; }
|
||
.diag-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 16px; }
|
||
.diag-item { display: flex; align-items: baseline; gap: 6px; padding: 4px 0; font-size: 0.84rem; border-bottom: 1px solid #f1f5f9; }
|
||
.diag-label { font-size: 0.72rem; font-weight: 600; color: #9ca3af; min-width: 72px; flex-shrink: 0; }
|
||
.ip-grid { display: flex; flex-direction: column; gap: 3px; }
|
||
.ip-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 0.84rem; }
|
||
.ip-role { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.3px; padding: 1px 6px; border-radius: 3px; min-width: 58px; text-align: center; }
|
||
.ip-role--internet { background: #dcfce7; color: #166534; }
|
||
.ip-role--management { background: #dbeafe; color: #1e40af; }
|
||
.ip-role--service { background: #f3e8ff; color: #6b21a8; }
|
||
.ip-role--lan { background: #f1f5f9; color: #475569; }
|
||
.ip-addr { font-size: 0.84rem; font-weight: 600; }
|
||
.ip-link { color: #1e40af; text-decoration: none; font-family: monospace; }
|
||
.ip-link:hover { text-decoration: underline; }
|
||
.mesh-nodes { display: flex; flex-direction: column; gap: 6px; }
|
||
.mesh-node { padding: 6px 8px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0; }
|
||
.mesh-node-header { display: flex; align-items: center; font-size: 0.84rem; }
|
||
.node-group { margin-bottom: 8px; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; }
|
||
.node-group-header { display: flex; align-items: center; padding: 6px 10px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; font-size: 0.82rem; cursor: pointer; user-select: none; }
|
||
.node-group-header:hover { background: #f1f5f9; }
|
||
.node-chevron { color: #9ca3af; margin-right: 2px; transition: transform 0.15s; }
|
||
.hosts-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
|
||
.hosts-table th { text-align: left; font-size: 0.68rem; font-weight: 600; color: #9ca3af; padding: 3px 6px; border-bottom: 1px solid #f1f5f9; }
|
||
.hosts-table td { padding: 3px 6px; border-bottom: 1px solid #f1f5f9; }
|
||
.hosts-table code { font-size: 0.72rem; }
|
||
.port-ctx-bar { height: 6px; border-radius: 3px; background: #f1f5f9; display: flex; overflow: hidden; }
|
||
.port-ctx-fill--online { background: #4ade80; }
|
||
.port-ctx-fill--offline { background: #f87171; }
|
||
.port-ctx-legend { display: flex; align-items: center; }
|
||
.port-ctx-neighbors { display: flex; flex-direction: column; gap: 2px; }
|
||
.port-ctx-neighbor { padding: 2px 6px; background: #fef2f2; border-radius: 4px; border: 1px solid #fecaca; }
|
||
/* Advanced diagnostic */
|
||
.adv-issues { display: flex; flex-direction: column; gap: 6px; }
|
||
.adv-issue { padding: 8px 10px; border-radius: 6px; font-size: 0.82rem; }
|
||
.adv-issue--critical { background: #fef2f2; border: 1px solid #fecaca; }
|
||
.adv-issue--warning { background: #fffbeb; border: 1px solid #fde68a; }
|
||
.adv-issue--info { background: #eff6ff; border: 1px solid #bfdbfe; }
|
||
.adv-issue-header { display: flex; align-items: center; gap: 6px; }
|
||
.adv-issue--critical .adv-issue-header { color: #991b1b; }
|
||
.adv-issue--warning .adv-issue-header { color: #92400e; }
|
||
.adv-issue--info .adv-issue-header { color: #1e40af; }
|
||
.adv-issue-detail { font-size: 0.76rem; color: #6b7280; margin-top: 2px; padding-left: 22px; }
|
||
.adv-issue-action { font-size: 0.76rem; color: #4b5563; margin-top: 3px; padding-left: 22px; font-style: italic; }
|
||
.adv-ok { padding: 8px 10px; background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 6px; font-size: 0.82rem; color: #166534; display: flex; align-items: center; }
|
||
.adv-mesh-nodes { display: flex; flex-direction: column; gap: 6px; }
|
||
.adv-mesh-node { padding: 8px 10px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; }
|
||
.adv-mesh-header { display: flex; align-items: center; font-size: 0.84rem; }
|
||
.adv-mesh-stats { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 4px; font-size: 0.78rem; }
|
||
.adv-mesh-stat { display: flex; align-items: baseline; gap: 4px; }
|
||
.adv-signal-bar { width: 40px; height: 6px; background: #e5e7eb; border-radius: 3px; display: inline-block; vertical-align: middle; margin-right: 4px; overflow: hidden; }
|
||
.adv-signal-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
|
||
.adv-clients-table td:first-child { white-space: nowrap; }
|
||
.adv-inactive-row { opacity: 0.5; font-style: italic; }
|
||
|
||
/* Mobile (agents terrain) : la carte réseau et les grilles doivent tenir dans
|
||
l'écran plutôt que déborder/rogner (nœuds topo à largeur fixe = cause du clip). */
|
||
@media (max-width: 600px) {
|
||
.topo-summary { padding: 10px; }
|
||
.topo-row { gap: 2px; }
|
||
.topo-node { width: auto; flex: 1 1 0; min-width: 0; }
|
||
.topo-cap, .topo-sub { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.topo-link { max-width: 18px; }
|
||
.dv-grid { grid-template-columns: repeat(2, 1fr); }
|
||
.dv-row2 { flex-direction: column; }
|
||
.diag-grid { grid-template-columns: 1fr; }
|
||
}
|
||
</style>
|