diff --git a/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue b/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue index 2f64065..5500b25 100644 --- a/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue +++ b/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue @@ -100,6 +100,13 @@
+ +
+ + {{ wStatus.online === true ? 'En ligne' : wStatus.online === false ? 'Hors ligne' : 'État inconnu' }} + · {{ wStatus.online ? 'depuis' : 'vu' }} {{ timeAgo(wStatus.since) }} + · IP {{ wStatus.framedIp }} +
@@ -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() }) @@ -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; } diff --git a/services/legacy-bridge/ops_airos.php b/services/legacy-bridge/ops_airos.php index 42d7df8..10664bd 100644 --- a/services/legacy-bridge/ops_airos.php +++ b/services/legacy-bridge/ops_airos.php @@ -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(); diff --git a/services/targo-hub/lib/ticket-collab.js b/services/targo-hub/lib/ticket-collab.js index 1843564..e187a23 100644 --- a/services/targo-hub/lib/ticket-collab.js +++ b/services/targo-hub/lib/ticket-collab.js @@ -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 }