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:
parent
3f416fe2ec
commit
db03b0cdab
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user