feat(ops): RADIUS-authoritative CPE MAC + live client-ethernet throughput

RADIUS MAC (user's ask — auto-follows equipment swaps): ops_airos.php resolves the
service's radius_user (device→service) and reads the latest radacct session →
callingstationid = current CPE MAC (auto-updated on WPA2-RADIUS re-auth) and
nasipaddress = the AP IP (reliable — replaces the flaky radio-MAC guess). Verified
on Bryson: RADIUS shows the current MAC and a *different* MAC 2 days prior (proving
the swap was auto-tracked). EquipmentDetail flags a RADIUS≠stored MAC mismatch with
a 1-click "Mettre à jour" (updateDoc) — manual serial/MAC edit (InlineField) stays.
The RADIUS DB isn't reachable from the hub (no GRANT) → done via the F bridge.

Live client-ethernet throughput (F parity): ops_airos returns eth0 rx/tx byte
counters + ts; EquipmentDetail's wireless panel gets a "Débit en direct" toggle that
polls every ~6s (self-rescheduling, no overlap) and draws ▼download/▲upload
sparklines (eth0 tx=download, rx=upload). Verified live: ↓8.0/↑1.9 kbps on Bryson.

Also: AP link now uses the reliable nasipaddress; signal tiles keep interval coloring.
Deployed (hub + F bridge + SPA index.27375132); leak-check 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-20 18:49:15 -04:00
parent 60d3f73623
commit 22593a06c3
3 changed files with 112 additions and 9 deletions

View File

@ -130,6 +130,32 @@
<div v-else-if="airos && !airos.ok" class="dsf-note">
<q-icon name="info" size="12px" /> {{ airos.registered === false ? 'Signal sans-fil en attente : webhook n8n « get_signal_airos » à configurer.' : airos.error }}
</div>
<!-- MAC RADIUS (WPA2) autoritaire MAC enregistrée (équipement remplacé ?) alignement 1-clic. -->
<div v-if="airos && airos.ok && macMismatch" class="dsf-macfix">
<q-icon name="sync_problem" size="13px" color="deep-orange-7" /> MAC RADIUS <code>{{ airos.radiusMac }}</code> enregistrée <code>{{ doc.mac_address || '—' }}</code>
<q-btn dense flat no-caps size="sm" color="primary" icon="sync" label="Mettre à jour" :loading="macSyncing" @click="syncMacFromRadius" />
</div>
<!-- Débit Ethernet client EN DIRECT (eth0 du CPE) façon F. À la demande (charge le CPE). download / upload. -->
<div v-if="airos && airos.ok" class="dsf-eth">
<div class="dsf-eth-row">
<span class="dsf-eth-tl"><q-icon name="show_chart" size="13px" /> Débit Ethernet client (eth0)</span>
<q-btn dense flat round size="9px" :icon="ethLive ? 'stop' : 'speed'" :color="ethLive ? 'red' : 'primary'" @click="toggleEthLive"><q-tooltip>{{ ethLive ? 'Arrêter la mesure' : 'Débit en direct — eth0 du CPE (façon F)' }}</q-tooltip></q-btn>
</div>
<template v-if="ethLive">
<div v-if="ethErr" class="dsf-note">{{ ethErr }}</div>
<template v-else>
<svg viewBox="0 0 220 40" class="wan-spark" preserveAspectRatio="none">
<polyline v-if="ethRates.length > 1" :points="ethLine('dl')" fill="none" stroke="#16a34a" stroke-width="1.5" />
<polyline v-if="ethRates.length > 1" :points="ethLine('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(ethNow && ethNow.dl) }}</span> &nbsp;
<span style="color:#2563eb;font-weight:600"> {{ fmtBps(ethNow && ethNow.ul) }}</span>
<q-spinner size="11px" class="q-ml-xs" v-if="!ethNow" /><span class="text-grey-5" v-if="!ethNow"> mesure (~12 s)</span>
</div>
</template>
</template>
</div>
</div>
<!-- Identité de l'appareil (repliée par défaut) -->
@ -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() })
</script>
<style scoped>
@ -1064,6 +1131,11 @@ onUnmounted(() => stopWanLive())
.dsf-chip { display: inline-flex; align-items: center; gap: 3px; font-size: 0.74rem; color: #334155; background: #fff; border: 1px solid #e2e8f0; border-radius: 5px; padding: 2px 7px; }
.dsf-chip.dsf-warn { background: #fffbeb; border-color: #fde68a; color: #92400e; }
.dsf-note { font-size: 0.74rem; color: #92400e; margin-top: 6px; display: flex; align-items: center; gap: 4px; }
.dsf-macfix { font-size: 0.74rem; color: #9a3412; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 6px; padding: 4px 8px; margin-top: 7px; display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
.dsf-macfix code { font-size: 0.72rem; }
.dsf-eth { margin-top: 8px; border-top: 1px dashed #e9d5ff; padding-top: 6px; }
.dsf-eth-row { display: flex; align-items: center; gap: 6px; }
.dsf-eth-tl { font-size: 0.72rem; color: #6b21a8; font-weight: 600; display: flex; align-items: center; gap: 3px; }
.dsf-sig { font-weight: 700; }
.dsf-open { display: inline-flex; align-items: center; gap: 3px; font-size: 0.74rem; color: #4f46e5; text-decoration: none; font-weight: 600; margin-right: 6px; }
.dsf-open:hover { text-decoration: underline; }

View File

@ -19,6 +19,11 @@ require __DIR__ . '/ops_secret.php'; // $OPS_TOKEN, $DB_USER, $
$DB_HOST = '10.100.80.100';
$DB_NAME = 'gestionclient';
// RADIUS (WPA2) = source AUTORITAIRE de la MAC courante du CPE (auto-MàJ au remplacement, via callingstationid) +
// IP de l'AP (nasipaddress). Mêmes creds que F airos_ac.php (le hub OPS n'a PAS le GRANT → passe par ce pont).
$RADIUS_HOST = '10.5.2.25';
$RADIUS_DB = 'radiusdb';
$RADIUS_PASS = 'N0HAk4u$';
function out($a, $c = 200) { http_response_code($c); echo json_encode($a, JSON_UNESCAPED_UNICODE); exit; }
@ -128,13 +133,29 @@ $signal = $sta['signal'] ?? ($w['signal'] ?? null);
$ccq = $sta['ccq'] ?? ($w['ccq'] ?? null);
if ($ccq !== null && (!is_numeric($ccq) || $ccq < 0 || $ccq > 100)) $ccq = null;
// IP de gestion de l'AP (borne) : résous la MAC radio de l'AP → ligne device correspondante (best-effort ; permet
// un lien direct vers l'AP dans OPS). La MAC radio ≠ toujours device.mac → si non trouvé, ap_ip reste null.
$ap_ip = null; $apmac = $w['apmac'] ?? '';
if ($apmac !== '') {
$st = $db->prepare("SELECT manage FROM device WHERE (mac=? OR mac=?) AND manage<>'' LIMIT 1");
if ($st) { $macNoColon = str_replace(array(':', '-'), '', $apmac); $st->bind_param('ss', $apmac, $macNoColon); $st->execute(); $r2 = $st->get_result()->fetch_assoc(); if ($r2) $ap_ip = $r2['manage']; }
// RADIUS (WPA2) = MAC COURANTE du CPE (callingstationid, auto-MàJ au remplacement d'équipement) + IP de l'AP
// (nasipaddress, fiable). On résout le radius_user via le service de l'appareil, puis la dernière session radacct.
$radius_user = null; $radius_mac = null; $ap_ip = null;
$stR = $db->prepare("SELECT radius_user FROM service WHERE device_id=? AND radius_user<>'' ORDER BY (status=1) DESC, id DESC LIMIT 1");
if ($stR) { $stR->bind_param('i', $row['id']); $stR->execute(); $rr = $stR->get_result()->fetch_assoc(); if ($rr) $radius_user = $rr['radius_user']; }
if ($radius_user) {
$rdb = @new mysqli($RADIUS_HOST, $DB_USER, $RADIUS_PASS, $RADIUS_DB);
if ($rdb && !$rdb->connect_errno) {
$stx = $rdb->prepare("SELECT callingstationid, nasipaddress FROM radacct WHERE username=? ORDER BY radacctid DESC LIMIT 1");
if ($stx) {
$stx->bind_param('s', $radius_user); $stx->execute(); $rx = $stx->get_result()->fetch_assoc();
if ($rx) {
$cm = strtoupper(trim(str_replace('-', ':', (string)$rx['callingstationid'])));
if (preg_match('/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/', $cm)) $radius_mac = $cm;
$nip = trim((string)$rx['nasipaddress']); if ($nip !== '' && $nip !== '0.0.0.0') $ap_ip = $nip;
}
}
$rdb->close();
}
}
// Repli AP IP si RADIUS muet : MAC radio de l'AP → ligne device (best-effort).
if ($ap_ip === null) { $apmac = $w['apmac'] ?? ''; if ($apmac !== '') { $st = $db->prepare("SELECT manage FROM device WHERE (mac=? OR mac=?) AND manage<>'' LIMIT 1"); if ($st) { $macNC = str_replace(array(':', '-'), '', $apmac); $st->bind_param('ss', $apmac, $macNC); $st->execute(); $r2 = $st->get_result()->fetch_assoc(); if ($r2) $ap_ip = $r2['manage']; } } }
$eth0b = $eth0 ?: array();
out(array(
'ok' => true, 'source' => 'f',
@ -146,6 +167,8 @@ out(array(
'tx_power' => $w['txpower'] ?? null,
'mac_ap' => $w['apmac'] ?? null,
'ap_ip' => $ap_ip,
'radius_user' => $radius_user,
'radius_mac' => $radius_mac,
'signal' => $signal,
'signal_ap' => $sta['remote']['signal'] ?? null,
'ccq' => $ccq,
@ -156,4 +179,8 @@ out(array(
'connection_time' => sec2t($host['uptime'] ?? 0),
'if_speed_lan0' => $lan($eth0),
'if_speed_lan1' => $lan($eth1),
// Compteurs d'octets eth0 (LAN client) → débit LIVE calculé côté OPS (Δoctets/Δs × 8). eth0 rx = upload, tx = download.
'eth_rx_bytes' => isset($eth0b['rx_bytes']) ? (float) $eth0b['rx_bytes'] : null,
'eth_tx_bytes' => isset($eth0b['tx_bytes']) ? (float) $eth0b['tx_bytes'] : null,
'ts' => round(microtime(true) * 1000),
));

View File

@ -819,6 +819,10 @@ function normalizeAiros (r) {
txRate: clean(r.tx_rate || r.txRate), rxRate: clean(r.rx_rate || r.rxRate),
capUp: num(r.airmax_capacity_uplink || r.capUp), capDown: num(r.airmax_capacity_downlink || r.capDown),
uptime: clean(r.connection_time || r.uptime), lan0: clean(r.if_speed_lan0 || r.lan0), lan1: clean(r.if_speed_lan1 || r.lan1),
// RADIUS (WPA2) : MAC courante autoritaire (auto-MàJ au remplacement) + IP AP (nasipaddress). Débit eth0 : compteurs + ts.
radiusMac: clean(r.radius_mac || r.radiusMac), radiusUser: clean(r.radius_user || r.radiusUser),
ethRx: (r.eth_rx_bytes != null ? Number(r.eth_rx_bytes) : null), ethTx: (r.eth_tx_bytes != null ? Number(r.eth_tx_bytes) : null),
ts: (r.ts != null ? Number(r.ts) : null),
}
}