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 }