diff --git a/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue b/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue
index 691e5ff..a36a2ed 100644
--- a/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue
+++ b/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue
@@ -130,6 +130,32 @@
{{ airos.registered === false ? 'Signal sans-fil en attente : webhook n8n « get_signal_airos » à configurer.' : airos.error }}
+
+
+ MAC RADIUS {{ airos.radiusMac }} ≠ enregistrée {{ doc.mac_address || '—' }}
+
+
+
+
+
+ Débit Ethernet client (eth0)
+ {{ ethLive ? 'Arrêter la mesure' : 'Débit en direct — eth0 du CPE (façon F)' }}
+
+
+ {{ ethErr }}
+
+
+
+ ▼ {{ fmtBps(ethNow && ethNow.dl) }}
+ ▲ {{ fmtBps(ethNow && ethNow.ul) }}
+ mesure… (~12 s)
+
+
+
+
@@ -585,7 +611,7 @@ 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 { deleteDoc, updateDoc } from 'src/api/erp'
import { HUB_URL } from 'src/config/hub'
const props = defineProps({ doc: { type: Object, required: true }, docName: String })
@@ -618,6 +644,46 @@ async function fetchAiros () {
airos.value = await r.json()
} catch (e) { airos.value = { ok: false, error: e.message } } finally { airosLoading.value = false }
}
+// MAC RADIUS (WPA2) = source autoritaire, s'auto-MàJ au remplacement d'équipement. Si elle diffère de la MAC
+// enregistrée → on le signale + bouton 1-clic pour l'aligner (updateDoc). La saisie manuelle reste dispo (InlineField).
+const normMacUi = (m) => String(m || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase()
+const macMismatch = computed(() => { const rm = airos.value && airos.value.radiusMac; return !!(rm && normMacUi(rm) && normMacUi(rm) !== normMacUi(props.doc.mac_address)) })
+const macSyncing = ref(false)
+async function syncMacFromRadius () {
+ const rm = airos.value && airos.value.radiusMac; if (!rm) return
+ macSyncing.value = true
+ try { await updateDoc('Service Equipment', props.docName, { mac_address: rm }); props.doc.mac_address = rm; $q.notify({ type: 'positive', message: 'MAC mise à jour depuis RADIUS : ' + rm }) }
+ catch (e) { $q.notify({ type: 'negative', message: 'Échec MAJ MAC : ' + (e.message || e) }) } finally { macSyncing.value = false }
+}
+
+// Débit Ethernet client EN DIRECT (eth0 du CPE) : on sonde /collab/airos-signal et on calcule (Δoctets/Δs)×8.
+// eth0 rx = upload (trafic DU client), tx = download (trafic VERS le client). Re-planifié (pas setInterval) → 0 chevauchement.
+const ethLive = ref(false); const ethRates = ref([]); const ethErr = ref('')
+let ethTimer = null; let ethPrev = null
+async function ethPoll () {
+ try {
+ const r = await fetch(`${HUB_URL}/collab/airos-signal?serial=${encodeURIComponent(props.doc.serial_number || '')}&ip=${encodeURIComponent(props.doc.ip_address || '')}`)
+ const d = await r.json()
+ if (!d || !d.ok || d.ethRx == null) { ethErr.value = (d && d.error) || 'débit indisponible sur ce CPE'; return }
+ ethErr.value = ''
+ const cur = { ts: d.ts || Date.now(), rx: d.ethRx, tx: d.ethTx }
+ if (ethPrev) {
+ const sec = (cur.ts - ethPrev.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 }
+ ethRates.value = [...ethRates.value, { dl: delta(ethPrev.tx, cur.tx), ul: delta(ethPrev.rx, cur.rx), ts: cur.ts }].slice(-30)
+ }
+ ethPrev = cur
+ } catch (e) { ethErr.value = 'erreur de mesure' }
+}
+function scheduleEth () { ethTimer = setTimeout(async () => { await ethPoll(); if (ethLive.value) scheduleEth() }, 6000) }
+function startEthLive () { ethLive.value = true; ethErr.value = ''; ethRates.value = []; ethPrev = null; ethPoll().then(() => { if (ethLive.value) scheduleEth() }) }
+function stopEthTimer () { if (ethTimer) { clearTimeout(ethTimer); ethTimer = null } }
+function stopEthLive () { stopEthTimer(); ethLive.value = false }
+function toggleEthLive () { ethLive.value ? stopEthLive() : startEthLive() }
+const ethNow = computed(() => ethRates.value.length ? ethRates.value[ethRates.value.length - 1] : null)
+const ethMax = computed(() => { let m = 1; for (const r of ethRates.value) { if (r.dl > m) m = r.dl; if (r.ul > m) m = r.ul } return m })
+function ethLine (key) { const r = ethRates.value; if (r.length < 2) return ''; const max = ethMax.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(' ') }
+
// Coloration du signal par INTERVALLE (parité F). Sans-fil (dBm airOS) : ≥-60 bon, -70 correct, -78 faible, < critique.
function wifiSigColor (s) { if (s == null) return '#64748b'; if (s >= -60) return '#16a34a'; if (s >= -70) return '#65a30d'; if (s >= -78) return '#d97706'; return '#dc2626' }
// Fibre (Rx optique dBm) : plage saine -8..-25 vert ; -25..-28 orange ; < -28 (ou > -8 sur-puissance) rouge.
@@ -1013,7 +1079,8 @@ onMounted(async () => {
// 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
+ stopWanLive(); stopEthLive() // arrêter les mesures live de l'appareil précédent
+ liveF.value = null; airos.value = null // repartir propre pour le nouvel appareil
if (sn) {
hosts.value = null
fetchStatus([{ serial_number: sn }])
@@ -1021,7 +1088,7 @@ watch(() => props.doc.serial_number, sn => {
loadEvents(); loadWanHistory() // rafraîchir la bande dispo/événements pour le nouvel appareil
}
})
-onUnmounted(() => stopWanLive())
+onUnmounted(() => { stopWanLive(); stopEthLive() })