gigafibre-fsm/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue
louispaulb da9c918fa9 feat(ux): gate ONU device actions + delete behind manage_settings, own block
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>
2026-07-19 10:30:45 -04:00

984 lines
55 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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> &nbsp;
<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 }} &middot; Ch {{ radio.channel || '?' }}
<template v-if="radio.bandwidth"> &middot; {{ 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"> &middot; {{ 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"> &middot; {{ 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">&middot; {{ 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>
&mdash; {{ 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"> &middot; {{ port.speed }} Mbps</template>
<template v-if="port.stats && port.status === 'Up'">
&middot; {{ 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>
&middot; {{ node.backhaul.linkRate }} Mbps
&middot; {{ 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 }} &middot; {{ r.bandwidth }} &middot; {{ r.standard }}
</span>
</div>
<div class="diag-item">
<span class="diag-label">{{ r.band }} config</span>
<span>Puissance {{ r.txPower }}% &middot; 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 || '?' }} &rarr; {{ 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 lOLT.',
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>