feat(ops): auto-sync CPE MAC from RADIUS into OPS (no manual step)

MAC updates now flow into OPS automatically. On any signal read (wireless panel
OR the assistant's check_signal), the hub's airosSignal resolves the Service
Equipment record and, if the RADIUS-authoritative MAC (radacct.callingstationid,
auto-updated on WPA2 re-auth) differs from the stored mac_address, it updates
Service Equipment server-side — idempotent, fill/correct, no click.

UI shows a green "MAC synchronisée automatiquement depuis RADIUS (était …)" note
and reflects it in the Identity field; the manual button remains only as a fallback
if the auto-update can't run. Manual serial/MAC edit (InlineField) still available.

Verified: corrupted EQP-0000100001 mac to 00:00:00:00:00:00 → one airosSignal read
self-healed it to RADIUS 24:5A:4C:30:CE:AA (logged). SPA deployed; leak-check 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-20 18:54:01 -04:00
parent 22593a06c3
commit 3f416fe2ec
2 changed files with 28 additions and 13 deletions

View File

@ -130,8 +130,11 @@
<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">
<!-- MAC RADIUS (WPA2) = autoritaire, AUTO-synchronisée côté serveur au remplacement d'équipement. -->
<div v-if="airos && airos.ok && airos.macSynced" class="dsf-note" style="color:#166534">
<q-icon name="check_circle" size="13px" color="green-7" /> MAC synchronisée automatiquement depuis RADIUS : <code>{{ airos.radiusMac }}</code><span v-if="airos.macWas"> (était {{ airos.macWas }})</span>
</div>
<div v-else-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>
@ -642,6 +645,8 @@ async function fetchAiros () {
try {
const r = await fetch(`${HUB_URL}/collab/airos-signal?serial=${encodeURIComponent(props.doc.serial_number || '')}&ip=${encodeURIComponent(props.doc.ip_address || '')}`)
airos.value = await r.json()
// Le hub a auto-synchronisé la MAC depuis RADIUS refléter localement (l'InlineField Identité suit).
if (airos.value && airos.value.macSynced && airos.value.radiusMac) props.doc.mac_address = airos.value.radiusMac
} 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

View File

@ -842,29 +842,39 @@ function httpsGetJson (targetUrl, headers, timeoutMs) {
})
}
const macNorm = (m) => String(m || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase()
async function airosSignal ({ sn, ip, port, user, mac } = {}) {
// Résout ip/mac depuis Service Equipment (source unique du parc) si non fournis.
if ((!ip || !mac) && sn) {
try {
const rows = await erp.list('Service Equipment', { filters: [['serial_number', '=', String(sn)]], fields: ['ip_address', 'mac_address'], limit: 1 })
const e = rows && rows[0]
if (e) { if (!ip) ip = e.ip_address || ''; if (!mac) mac = e.mac_address || '' }
} catch (e) { /* best-effort */ }
}
// Résout le Service Equipment (nom + IP + MAC enregistrée) — sert à l'AUTO-SYNC de la MAC depuis RADIUS.
let seName = null, seMac = ''
try {
let rows = []
if (sn) rows = await erp.list('Service Equipment', { filters: [['serial_number', '=', String(sn)]], fields: ['name', 'ip_address', 'mac_address'], limit: 1 })
if ((!rows || !rows.length) && ip) rows = await erp.list('Service Equipment', { filters: [['ip_address', '=', String(ip)]], fields: ['name', 'ip_address', 'mac_address'], limit: 1 })
const e = rows && rows[0]
if (e) { seName = e.name; seMac = e.mac_address || ''; if (!ip) ip = e.ip_address || ''; if (!mac) mac = e.mac_address || '' }
} catch (e) { /* best-effort */ }
if (!sn && !ip && !mac) return { ok: false, error: 'serial, ip ou mac requis' }
// PRIMAIRE : pont F ops_airos.php (même hôte + token que ops_reassign.php).
const token = opsBridgeToken()
const base = (process.env.OPS_LEGACY_URL || 'https://facturation.targo.ca/ops_reassign.php').replace(/ops_reassign\.php.*$/, 'ops_airos.php')
let out = null
if (token && base) {
const q = new URLSearchParams()
if (sn) q.set('serial', sn); if (ip) q.set('ip', ip); if (mac) q.set('mac', mac); if (port) q.set('port', String(port))
const r = await httpsGetJson(base + '?' + q.toString(), { 'X-Ops-Token': token }, 25000)
if (r && r.ok === true) return normalizeAiros(r)
if (r && r.ok === false && r.error && !r._err) return { ok: false, error: r.error } // réponse F propre (device introuvable / CPE injoignable)
if (r && r.ok === true) out = normalizeAiros(r)
else if (r && r.ok === false && r.error && !r._err) return { ok: false, error: r.error } // réponse F propre (device introuvable / CPE injoignable)
// transport KO (r._err) → tenter n8n en repli
}
// REPLI : webhook n8n get_signal_airos (post-migration F ; 404 = pas encore créé).
return await airosViaN8n({ sn, ip, port, user })
if (!out) out = await airosViaN8n({ sn, ip, port, user })
// AUTO-SYNC MAC : RADIUS (WPA2) = autoritaire, s'auto-MàJ au remplacement. Si la MAC courante diffère de
// l'enregistrée → on met à jour Service Equipment AUTOMATIQUEMENT (côté serveur, fill/correct, idempotent).
if (out && out.ok && out.radiusMac && seName && macNorm(out.radiusMac) && macNorm(out.radiusMac) !== macNorm(seMac)) {
try { await erp.update('Service Equipment', seName, { mac_address: out.radiusMac }); out.macSynced = true; out.macWas = seMac || null; log('[airos] MAC auto-sync ' + seName + ' ' + (seMac || '∅') + ' → ' + out.radiusMac + ' (RADIUS)') }
catch (e) { out.macSyncError = e.message }
}
return out
}
function airosViaN8n ({ sn, ip, port, user } = {}) {