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:
parent
60d3f73623
commit
22593a06c3
|
|
@ -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>
|
||||
<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; }
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
));
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user