feat(ops): page-load wireless status + daily 03h RADIUS MAC reconcile

Fast page-load status (no slow status.cgi): ops_airos.php gains a `mode=status`
path that returns RADIUS-only state (online, since, current MAC, AP IP) in ~ms.
Hub airosStatus() + GET /collab/airos-status; EquipmentDetail loads it on mount for
wireless → "● En ligne depuis 9h · IP …" badge (with MAC auto-sync on this call too).

Daily park-wide reconcile (03h ET): ops_airos.php `bulk=1` returns every wireless
service's current radacct MAC in one call (2 queries + merge); hub syncWirelessMacs()
indexes Service Equipment by mgmt IP and updates only changed MACs. Scheduled via a
/30-min tick that fires once in the 03h ET window (WIRELESS_MAC_SYNC=off to disable),
started lazily on first request (not at require → no timer in CLI/tests). Manual
trigger: POST /collab/airos-mac-sync.

Fixed erp.update success detection (returns {ok:false}, never throws) in autoSyncMac
+ syncWirelessMacs — counts are now accurate. Verified: reconcile bulk 351 / checked
333 / updated / errs=18 = pre-existing broken service_location links (flagged
separately, non-blocking). Deployed hub+F+SPA (index.5444d59b); leak 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-20 19:12:06 -04:00
parent 3f416fe2ec
commit db03b0cdab
3 changed files with 162 additions and 28 deletions

View File

@ -100,6 +100,13 @@
<!-- Do Stuff SANS-FIL (airOS/Cambium) : signal / signal AP / CCQ / débit / capacité airMAX live du CPE, via le
webhook n8n get_signal_airos (le hub ne joint pas les CPE) parité avec le device_view sans-fil de F. -->
<div v-if="isWireless" class="dsf dsf-wl">
<!-- État RAPIDE au chargement (RADIUS, sans status.cgi) : en ligne / hors ligne + depuis + IP client. -->
<div v-if="wStatus && wStatus.ok" class="dsf-wstatus">
<span class="dsf-dot" :class="wStatus.online === true ? 'on' : wStatus.online === false ? 'off' : 'unk'"></span>
<span class="text-weight-medium">{{ wStatus.online === true ? 'En ligne' : wStatus.online === false ? 'Hors ligne' : 'État inconnu' }}</span>
<span v-if="wStatus.since" class="text-grey-6">· {{ wStatus.online ? 'depuis' : 'vu' }} {{ timeAgo(wStatus.since) }}</span>
<span v-if="wStatus.framedIp" class="text-grey-6">· IP {{ wStatus.framedIp }}</span>
</div>
<div class="dsf-bar">
<q-btn v-if="!airos && !airosLoading" dense flat no-caps size="sm" color="deep-purple-6" icon="wifi_tethering"
label="Do Stuff en direct (sans-fil)" @click="fetchAiros">
@ -649,6 +656,17 @@ async function fetchAiros () {
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 }
}
// État RAPIDE au chargement (sans-fil) : RADIUS seul (en ligne / depuis / IP), sans le status.cgi lent. Auto-sync MAC aussi.
const wStatus = ref(null)
async function loadWStatus () {
if (!isWireless.value || (!props.doc.serial_number && !props.doc.ip_address)) return
try {
const r = await fetch(`${HUB_URL}/collab/airos-status?serial=${encodeURIComponent(props.doc.serial_number || '')}&ip=${encodeURIComponent(props.doc.ip_address || '')}`)
wStatus.value = await r.json()
if (wStatus.value && wStatus.value.macSynced && wStatus.value.radiusMac) props.doc.mac_address = wStatus.value.radiusMac
} catch (e) { wStatus.value = null }
}
// 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()
@ -1079,19 +1097,21 @@ onMounted(async () => {
loadEvents()
loadWanHistory()
}
loadWStatus() // sans-fil : état rapide RADIUS au chargement (self-guard isWireless)
})
// 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(); stopEthLive() // arrêter les mesures live de l'appareil précédent
liveF.value = null; airos.value = null // repartir propre pour le nouvel appareil
liveF.value = null; airos.value = null; wStatus.value = null // repartir propre pour le nouvel appareil
if (sn) {
hosts.value = null
fetchStatus([{ serial_number: sn }])
fetchOltStatus(sn)
loadEvents(); loadWanHistory() // rafraîchir la bande dispo/événements pour le nouvel appareil
}
loadWStatus()
})
onUnmounted(() => { stopWanLive(); stopEthLive() })
</script>
@ -1141,6 +1161,11 @@ onUnmounted(() => { stopWanLive(); stopEthLive() })
.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-wstatus { display: flex; align-items: center; gap: 5px; font-size: 0.78rem; color: #334155; margin-bottom: 6px; }
.dsf-dot { width: 9px; height: 9px; border-radius: 999px; display: inline-block; }
.dsf-dot.on { background: #21BA45; box-shadow: 0 0 4px #21BA4588; }
.dsf-dot.off { background: #C10015; }
.dsf-dot.unk { background: #9ca3af; }
.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

@ -87,6 +87,39 @@ $db = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME);
if ($db->connect_errno) out(['ok' => false, 'error' => 'db_connect'], 500);
$db->set_charset('utf8');
// ─── helpers RADIUS ───
function norm_mac($m) { $x = strtoupper(trim(str_replace('-', ':', (string) $m))); return preg_match('/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/', $x) ? $x : null; }
function radacct_latest($rdb, $username) {
$st = $rdb->prepare("SELECT callingstationid, acctstoptime, framedipaddress, nasipaddress, acctstarttime FROM radacct WHERE username=? ORDER BY radacctid DESC LIMIT 1");
if (!$st) return null; $st->bind_param('s', $username); $st->execute(); return $st->get_result()->fetch_assoc() ?: null;
}
// ─── MODE BULK (?bulk=1) : réconciliation MAC park-wide (job quotidien 03h). MAC RADIUS courante par appareil sans-fil. ───
if (($_REQUEST['bulk'] ?? '') === '1') {
$rows = array(); $users = array();
$rs = $db->query("SELECT d.id device_id, d.manage, d.mac, s.radius_user FROM device d JOIN service s ON s.device_id=d.id AND s.radius_user<>'' AND s.status=1 WHERE d.category IN ('airos_ac','airosm','cambium') AND d.manage<>''");
if ($rs) while ($r = $rs->fetch_assoc()) { $rows[] = $r; $users[$r['radius_user']] = true; }
$users = array_keys($users);
$macByUser = array();
$rdb = @new mysqli($RADIUS_HOST, $DB_USER, $RADIUS_PASS, $RADIUS_DB);
if ($rdb && !$rdb->connect_errno) {
for ($i = 0; $i < count($users); $i += 400) {
$batch = array_slice($users, $i, 400); if (!$batch) break;
$in = implode(',', array_fill(0, count($batch), '?'));
$st = $rdb->prepare("SELECT r.username, r.callingstationid, r.acctstoptime FROM radacct r INNER JOIN (SELECT username, MAX(radacctid) mx FROM radacct WHERE username IN ($in) GROUP BY username) g ON r.radacctid = g.mx");
if ($st) { $st->bind_param(str_repeat('s', count($batch)), ...$batch); $st->execute(); $res = $st->get_result(); while ($x = $res->fetch_assoc()) $macByUser[$x['username']] = $x; }
}
$rdb->close();
}
$devs = array();
foreach ($rows as $r) {
$ra = $macByUser[$r['radius_user']] ?? null; if (!$ra) continue;
$cm = norm_mac($ra['callingstationid']); if (!$cm) continue;
$devs[] = array('device_id' => (int) $r['device_id'], 'radius_user' => $r['radius_user'], 'manage' => $r['manage'], 'device_mac' => $r['mac'], 'radius_mac' => $cm, 'online' => ($ra['acctstoptime'] === null || $ra['acctstoptime'] === ''));
}
out(array('ok' => true, 'count' => count($devs), 'devices' => $devs));
}
$row = null;
$fetch1 = function ($sql, $type, $val) use ($db) {
$st = $db->prepare($sql); if (!$st) return null;
@ -113,6 +146,25 @@ $pwd = '';
if (trim((string)$row['pass']) !== '' && function_exists('mcrypt')) { $pwd = @mcrypt('decrypt', $row['pass']); }
if ($user === '' || $pwd === '' || $pwd === false) { $user = 'admin'; $pwd = 'N0HAk4u$'; } // repli = défaut usine F
// RADIUS (WPA2) : radius_user (service) → dernière session radacct → MAC courante + online + IPs (nasip = AP fiable).
$radius_user = null; $radius_mac = null; $ap_ip = null; $r_online = null; $framed_ip = null; $r_since = 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) {
$ra = radacct_latest($rdb, $radius_user);
if ($ra) { $radius_mac = norm_mac($ra['callingstationid']); $nip = trim((string) $ra['nasipaddress']); if ($nip !== '' && $nip !== '0.0.0.0') $ap_ip = $nip; $r_online = ($ra['acctstoptime'] === null || $ra['acctstoptime'] === ''); $framed_ip = trim((string) $ra['framedipaddress']) ?: null; $r_since = $ra['acctstarttime'] ?: null; }
$rdb->close();
}
}
// MODE STATUS (?mode=status) : état RAPIDE via RADIUS seul (aucun status.cgi) — pour l'affichage au chargement de la page.
if (($_REQUEST['mode'] ?? '') === 'status') {
out(array('ok' => true, 'source' => 'radius', 'kind' => 'sans-fil', 'model' => ($row['model'] ?: null),
'online' => $r_online, 'since' => $r_since, 'radius_user' => $radius_user, 'radius_mac' => $radius_mac, 'framed_ip' => $framed_ip, 'ap_ip' => $ap_ip));
}
$j = airOS_getFile_HTTPS($user, $pwd, 'status.cgi', $addr);
$s = $j ? json_decode($j, true) : null;
if (!is_array($s) || !isset($s['host'])) out(['ok' => false, 'error' => 'CPE injoignable ou réponse invalide', 'addr' => $addr], 200);
@ -133,26 +185,6 @@ $signal = $sta['signal'] ?? ($w['signal'] ?? null);
$ccq = $sta['ccq'] ?? ($w['ccq'] ?? null);
if ($ccq !== null && (!is_numeric($ccq) || $ccq < 0 || $ccq > 100)) $ccq = null;
// 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();

View File

@ -568,6 +568,7 @@ async function terminatedActive (refresh) {
}
async function handle (req, res, method, p, url) {
if (!_macSyncStarted) { _macSyncStarted = true; try { startWirelessMacSync() } catch (e) { /* */ } } // planificateur démarré au 1er requête (pas au require → pas de timer en test)
// POST /collab/orchestrate {text} → PLAN d'actions (ne s'exécute pas). POST /collab/orchestrate/run {actions} → exécute (confirmé).
if (p === '/collab/orchestrate' && method === 'POST') {
const b = await parseBody(req); return json(res, 200, await orchestratePlan(b.text || ''))
@ -687,6 +688,14 @@ async function handle (req, res, method, p, url) {
if (p === '/collab/airos-signal' && method === 'GET') {
return json(res, 200, await airosSignal({ sn: url.searchParams.get('serial') || url.searchParams.get('sn') || '', ip: url.searchParams.get('ip') || '', port: url.searchParams.get('port') || '', user: url.searchParams.get('user') || '' }))
}
// GET /collab/airos-status?serial=|ip= — état RAPIDE (RADIUS seul, pas de status.cgi) pour le chargement de page.
if (p === '/collab/airos-status' && method === 'GET') {
return json(res, 200, await airosStatus({ sn: url.searchParams.get('serial') || url.searchParams.get('sn') || '', ip: url.searchParams.get('ip') || '' }))
}
// POST /collab/airos-mac-sync — réconciliation MAC park-wide à la demande (le planificateur 03h l'appelle aussi).
if (p === '/collab/airos-mac-sync' && method === 'POST') {
return json(res, 200, await syncWirelessMacs())
}
// GET /collab/negative-billing[?refresh=1] — comptes dont le total récurrent mensuel ACTIF est < 0 (rabais > frais)
if (p === '/collab/negative-billing' && method === 'GET') {
return json(res, 200, await negativeBilling(url.searchParams.get('refresh') === '1'))
@ -843,6 +852,14 @@ function httpsGetJson (targetUrl, headers, timeoutMs) {
}
const macNorm = (m) => String(m || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase()
// AUTO-SYNC : RADIUS = MAC autoritaire. Si elle diffère de l'enregistrée → MàJ Service Equipment (idempotent).
async function autoSyncMac (out, seName, seMac) {
if (!(out && out.ok && out.radiusMac && seName)) return
if (!macNorm(out.radiusMac) || macNorm(out.radiusMac) === macNorm(seMac)) return
const res = await erp.update('Service Equipment', seName, { mac_address: out.radiusMac }).catch(e => ({ ok: false, error: String(e.message || e) }))
if (res && res.ok) { out.macSynced = true; out.macWas = seMac || null; log('[airos] MAC auto-sync ' + seName + ' ' + (seMac || '∅') + ' → ' + out.radiusMac + ' (RADIUS)') }
else { out.macSyncError = String((res && res.error) || 'échec').slice(0, 120) } // ex. lien service_location cassé → non bloquant
}
async function airosSignal ({ sn, ip, port, user, mac } = {}) {
// Résout le Service Equipment (nom + IP + MAC enregistrée) — sert à l'AUTO-SYNC de la MAC depuis RADIUS.
let seName = null, seMac = ''
@ -868,15 +885,75 @@ async function airosSignal ({ sn, ip, port, user, mac } = {}) {
}
// REPLI : webhook n8n get_signal_airos (post-migration F ; 404 = pas encore créé).
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 }
}
// AUTO-SYNC MAC : RADIUS (WPA2) = autoritaire, s'auto-MàJ au remplacement → on aligne Service Equipment (serveur).
await autoSyncMac(out, seName, seMac)
return out
}
// STATUS RAPIDE (chargement de page) : état via RADIUS seul (aucun status.cgi ~5 s). En ligne / depuis / MAC / IPs.
async function airosStatus ({ sn, ip } = {}) {
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 || '' }
} catch (e) { /* best-effort */ }
if (!sn && !ip) return { ok: false, error: 'serial ou ip requis' }
const token = opsBridgeToken()
const base = (process.env.OPS_LEGACY_URL || 'https://facturation.targo.ca/ops_reassign.php').replace(/ops_reassign\.php.*$/, 'ops_airos.php')
if (!token || !base) return { ok: false, error: 'pont F non configuré' }
const q = new URLSearchParams({ mode: 'status' })
if (sn) q.set('serial', sn); if (ip) q.set('ip', ip)
const r = await httpsGetJson(base + '?' + q.toString(), { 'X-Ops-Token': token }, 15000)
if (!r || r._err) return { ok: false, error: (r && r._err) || 'pont F injoignable' }
if (r.ok === false) return { ok: false, error: r.error }
const out = { ok: true, kind: 'sans-fil', online: (r.online === true || r.online === false) ? r.online : null, since: r.since || null, model: r.model || null, radiusMac: r.radius_mac || null, framedIp: r.framed_ip || null, apIp: r.ap_ip || null }
await autoSyncMac(out, seName, seMac) // le status porte aussi la MAC RADIUS → auto-sync au chargement de page
return out
}
// Réconciliation MAC park-wide (job quotidien 03 h) : 1 appel bulk F (radacct courant) → MàJ Service Equipment changées.
async function syncWirelessMacs () {
const token = opsBridgeToken()
const base = (process.env.OPS_LEGACY_URL || 'https://facturation.targo.ca/ops_reassign.php').replace(/ops_reassign\.php.*$/, 'ops_airos.php')
if (!token || !base) return { ok: false, error: 'pont F non configuré' }
const r = await httpsGetJson(base + '?bulk=1', { 'X-Ops-Token': token }, 60000)
if (!r || r._err || !Array.isArray(r.devices)) return { ok: false, error: (r && r._err) || 'bulk KO' }
// Index des Service Equipment sans-fil par IP de gestion (clé stable même quand la MAC change).
const existing = await erp.list('Service Equipment', { filters: [['ip_address', '!=', '']], fields: ['name', 'ip_address', 'mac_address'], limit: 8000 }).catch(() => [])
const byIp = {}; for (const e of existing) if (e.ip_address) byIp[String(e.ip_address).trim()] = e
let checked = 0, updated = 0, errs = 0, brokenLoc = 0
for (const d of r.devices) {
if (!d.radius_mac || !d.manage) continue
const se = byIp[String(d.manage).trim()]; if (!se) continue
checked++
if (macNorm(se.mac_address) !== macNorm(d.radius_mac)) {
const res = await erp.update('Service Equipment', se.name, { mac_address: d.radius_mac }).catch(e => ({ ok: false, error: String(e.message || e) }))
if (res && res.ok) updated++
else { errs++; if (/LinkValidation|Lieu de service/i.test(String(res && res.error))) brokenLoc++ }
}
}
log('[airos] daily MAC sync — bulk ' + r.devices.length + ' · checked ' + checked + ' · updated ' + updated + ' · errs ' + errs + (brokenLoc ? ' (dont ' + brokenLoc + ' lien service_location cassé)' : ''))
return { ok: true, bulk: r.devices.length, checked, updated, errs, broken_location: brokenLoc }
}
// Planificateur quotidien 03 h ET (tick /30 min ; s'exécute 1×/jour dans la fenêtre 03h). Désactivable WIRELESS_MAC_SYNC=off.
let _lastMacSyncDay = null; let _macSyncStarted = false
function startWirelessMacSync () {
if (process.env.WIRELESS_MAC_SYNC === 'off') return
const tick = async () => {
try {
const now = new Date()
const day = now.toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
const hour = parseInt(new Intl.DateTimeFormat('en-US', { timeZone: 'America/Toronto', hour: 'numeric', hour12: false }).format(now), 10)
if (hour === 3 && _lastMacSyncDay !== day) { _lastMacSyncDay = day; await syncWirelessMacs() }
} catch (e) { log('[airos] mac-sync tick: ' + e.message) }
}
setInterval(tick, 30 * 60 * 1000)
log('[airos] wireless MAC daily reconcile scheduled (03 h ET)')
}
function airosViaN8n ({ sn, ip, port, user } = {}) {
return new Promise((resolve) => {
let https; try { https = require('https') } catch (e) { return resolve({ ok: false, error: 'https indisponible' }) }
@ -896,4 +973,4 @@ function airosViaN8n ({ sn, ip, port, user } = {}) {
})
}
module.exports = { handle, addComment, activity, createTicket, serviceStatus, dostuffStatus, airosSignal }
module.exports = { handle, addComment, activity, createTicket, serviceStatus, dostuffStatus, airosSignal, airosStatus, syncWirelessMacs, startWirelessMacSync }