diff --git a/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue b/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue
index e455877..ebec968 100644
--- a/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue
+++ b/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue
@@ -160,18 +160,14 @@
Interface
-
-
Signal {{ airos.signal }} dBm · AP {{ airos.signalAp }}
-
CCQ {{ airos.ccq }} %
-
TX/RX {{ airos.txRate || '—' }} / {{ airos.rxRate || '—' }}
-
airMAX ▼{{ airos.capDown != null ? airos.capDown : '—' }} ▲{{ airos.capUp != null ? airos.capUp : '—' }} Mbps
-
{{ airos.ssid }}
-
{{ airos.frequency }}
-
TX {{ airos.txPower }}
-
{{ airos.uptime }}
-
{{ airos.version }}
-
AP {{ airos.apIp }}
-
AP {{ airos.apMac }}
+
+
+ {{ c.label }}
+ {{ c.value }}
+
+ {{ c.sub }}
+
{{ airos.registered === false ? 'Signal sans-fil en attente : webhook n8n « get_signal_airos » à configurer.' : airos.error }}
@@ -205,6 +201,26 @@
+
+
+
+ Appareils du client (DHCP){{ leases ? ' · ' + leases.length : '' }}
+ Lister les appareils derrière le CPE (baux DHCP)
+
+
{{ leasesErr }}
+
+ | Appareil | IP | MAC | Bail |
+
+
+ | {{ l.host || l.vendor || '—' }} {{ l.vendor }} |
+ {{ l.ip }} |
+ {{ l.mac }} |
+ {{ fmtLeaseRemain(l.remaining_s) }} |
+
+
+
+
Aucun appareil (aucun bail DHCP actif sur le CPE)
+
@@ -705,6 +721,19 @@ async function fetchAiros () {
} 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.
+// Baux DHCP = les APPAREILS derrière le CPE sans-fil (façon F « Refresh Leases ») — à la demande (SSH sur le CPE).
+const leases = ref(null); const leasesLoading = ref(false); const leasesErr = ref('')
+async function loadLeases () {
+ if (leasesLoading.value) return
+ leasesLoading.value = true; leasesErr.value = ''
+ try {
+ const r = await fetch(`${HUB_URL}/collab/airos-leases?serial=${encodeURIComponent(props.doc.serial_number || '')}&ip=${encodeURIComponent(props.doc.ip_address || '')}`)
+ const d = await r.json()
+ if (d && d.ok) { leases.value = d.leases || [] } else { leasesErr.value = (d && d.error) || 'indisponible'; leases.value = null }
+ } catch (e) { leasesErr.value = 'erreur de lecture' } finally { leasesLoading.value = false }
+}
+function fmtLeaseRemain (s) { if (!s || s <= 0) return '—'; const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60); return h > 0 ? (h + 'h' + (m ? m + 'm' : '')) : (m + 'm') }
+
const wStatus = ref(null)
async function loadWStatus () {
if (!isWireless.value || (!props.doc.serial_number && !props.doc.ip_address)) return
@@ -780,7 +809,18 @@ const statCards = computed(() => {
const rx = (lf && lf.rxPower != null) ? lf.rxPower : ((d.rxPower != null && d.rxPower !== 0) ? d.rxPower : null)
const tx = (lf && lf.txPower != null) ? lf.txPower : ((d.txPower != null && d.txPower !== 0) ? d.txPower : null)
if (rx != null) cards.push({ key: 'sig', icon: 'network_check', label: 'Signal fibre', value: rx + (tx != null ? ' / ' + tx : '') + ' dBm', color: fibreRxColor(rx), meter: fibreMeterPct(rx), sub: fibreQualWord(rx) + ' — sain de −8 à −25 dBm' })
- else if (isWireless.value && airos.value && airos.value.ok && airos.value.signal != null) cards.push({ key: 'sigw', icon: 'network_check', label: 'Signal sans-fil', value: airos.value.signal + ' dBm', color: wifiSigColor(airos.value.signal), meter: Math.max(4, Math.min(100, Math.round(((airos.value.signal + 90) / 40) * 100))), sub: (airos.value.signal >= -70 ? 'Bon' : airos.value.signal >= -78 ? 'Faible' : 'Critique') })
+ else if (isWireless.value && airos.value && airos.value.ok && airos.value.signal != null) cards.push({ key: 'sigw', icon: 'network_check', label: 'Signal sans-fil', value: airos.value.signal + ' dBm', color: wifiSigColor(airos.value.signal), meter: Math.max(4, Math.min(100, Math.round(((airos.value.signal + 90) / 40) * 100))), sub: (airos.value.signal >= -70 ? 'Bon' : airos.value.signal >= -78 ? 'Faible' : 'Critique') + ' — sain > −70 dBm' })
+ const aw = (isWireless.value && airos.value && airos.value.ok) ? airos.value : null
+ if (aw) {
+ if (aw.signalAp != null) cards.push({ key: 'sigap', icon: 'cell_tower', label: 'Signal côté borne', value: aw.signalAp + ' dBm', color: wifiSigColor(aw.signalAp) })
+ if (aw.ccq != null) cards.push({ key: 'ccq', icon: 'verified', label: 'Qualité (CCQ)', value: aw.ccq + ' %', sub: 'transmission' })
+ if (aw.txRate || aw.rxRate) cards.push({ key: 'rate', icon: 'swap_vert', label: 'Débit lien TX/RX', value: (aw.txRate || '—') + ' / ' + (aw.rxRate || '—') })
+ if (aw.capDown != null || aw.capUp != null) cards.push({ key: 'amax', icon: 'speed', label: 'Capacité airMAX', value: '▼' + (aw.capDown != null ? aw.capDown : '—') + ' ▲' + (aw.capUp != null ? aw.capUp : '—'), sub: 'Mbps possibles' })
+ if (aw.ssid) cards.push({ key: 'ssid', icon: 'rss_feed', label: 'SSID', value: aw.ssid })
+ if (aw.frequency) cards.push({ key: 'freq', icon: 'settings_input_antenna', label: 'Fréquence', value: String(aw.frequency) })
+ if (aw.apIp) cards.push({ key: 'apip', icon: 'cell_tower', label: 'Point d’accès', value: aw.apIp, mono: true, link: mgmtUrl(aw.apIp), sub: 'borne (tour)' })
+ else if (aw.apMac) cards.push({ key: 'apmac', icon: 'cell_tower', label: 'Point d’accès', value: aw.apMac, mono: true, sub: 'MAC de la borne' })
+ }
const up = (lf && lf.uptime) ? lf.uptime : (d.uptime ? formatUptime(d.uptime) : (airos.value && airos.value.ok && airos.value.uptime) || null)
if (up) cards.push({ key: 'up', icon: 'schedule', label: 'Uptime', value: up, sub: 'depuis le dernier redémarrage' })
const wan = (lf && lf.wanIp) || d.ip || null
@@ -1198,7 +1238,7 @@ onMounted(async () => {
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; wStatus.value = null // repartir propre pour le nouvel appareil
+ liveF.value = null; airos.value = null; wStatus.value = null; leases.value = null; leasesErr.value = '' // repartir propre pour le nouvel appareil
if (sn) {
hosts.value = null
fetchStatus([{ serial_number: sn }])
@@ -1267,6 +1307,12 @@ 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; }
+.lease-tbl { width: 100%; border-collapse: collapse; font-size: 0.76rem; margin-top: 6px; }
+.lease-tbl th { text-align: left; font-size: 0.66rem; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: .3px; padding: 3px 6px; border-bottom: 1px solid #eef2f7; }
+.lease-tbl td { padding: 4px 6px; border-bottom: 1px solid #f4f7fb; color: #334155; vertical-align: top; }
+.lease-tbl code { font-size: 0.72rem; }
+.lease-mac { color: #64748b; }
+.lease-sub { font-size: 0.66rem; color: #94a3b8; }
.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; }
diff --git a/services/legacy-bridge/ops_airos.php b/services/legacy-bridge/ops_airos.php
index 10664bd..d938fa5 100644
--- a/services/legacy-bridge/ops_airos.php
+++ b/services/legacy-bridge/ops_airos.php
@@ -146,6 +146,29 @@ $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
+// MODE LEASES (?mode=leases) : baux DHCP = les APPAREILS derrière le CPE (façon F). SSH sur le CPE (port 2194) →
+// cat /tmp/dhcpd.leases → {mac, ip, host, vendor (OUI), remaining_s}. Le CPE = serveur DHCP du client.
+if (($_REQUEST['mode'] ?? '') === 'leases') {
+ if (!function_exists('ssh2_connect')) out(['ok' => false, 'error' => 'ssh2 indisponible sur le pont']);
+ $conn = @ssh2_connect($mip, 2194);
+ if (!$conn || !@ssh2_auth_password($conn, $user, $pwd)) out(['ok' => false, 'error' => 'SSH refusé (auth CPE)']);
+ $stream = @ssh2_exec($conn, 'cat /tmp/dhcpd.leases');
+ if (!$stream) out(['ok' => false, 'error' => 'lecture baux impossible']);
+ stream_set_blocking($stream, true); $raw = stream_get_contents($stream);
+ $now = time(); $leases = array();
+ foreach (preg_split('/\r?\n/', (string) $raw) as $line) {
+ $line = trim($line); if ($line === '') continue;
+ $d = preg_split('/\s+/', $line); if (count($d) < 3) continue;
+ $exp = is_numeric($d[0]) ? ((int) $d[0] - $now) : 0;
+ $mac = $d[1] ?? ''; $ip = $d[2] ?? ''; $host = $d[3] ?? '';
+ $oui = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', substr($mac, 0, 8)));
+ $vendor = null;
+ if (strlen($oui) >= 6) { $v = @shell_exec('grep -i ' . escapeshellarg(substr($oui, 0, 6)) . " /targo/facturation/oui.txt | awk -F '\\t' '{print \$3}' | head -1"); $vendor = ($v && trim($v) !== '') ? trim($v) : null; }
+ $leases[] = array('mac' => $mac, 'ip' => $ip, 'host' => (($host && $host !== '*') ? $host : null), 'vendor' => $vendor, 'remaining_s' => ($exp > 0 ? $exp : 0));
+ }
+ out(['ok' => true, 'count' => count($leases), 'leases' => $leases]);
+}
+
// 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");
diff --git a/services/targo-hub/lib/ticket-collab.js b/services/targo-hub/lib/ticket-collab.js
index 67b3045..0685697 100644
--- a/services/targo-hub/lib/ticket-collab.js
+++ b/services/targo-hub/lib/ticket-collab.js
@@ -724,6 +724,10 @@ async function handle (req, res, method, p, url) {
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') || '' }))
}
+ // GET /collab/airos-leases?serial=|ip= — baux DHCP (appareils derrière le CPE sans-fil), via SSH sur le pont F.
+ if (p === '/collab/airos-leases' && method === 'GET') {
+ return json(res, 200, await airosLeases({ 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())
@@ -978,6 +982,19 @@ async function airosStatus ({ sn, ip } = {}) {
return out
}
+// Baux DHCP d'un CPE sans-fil (= appareils derrière le CPE, façon F) via le pont F (SSH → /tmp/dhcpd.leases).
+async function airosLeases ({ sn, ip } = {}) {
+ if (!ip && sn) { try { const rows = await erp.list('Service Equipment', { filters: [['serial_number', '=', String(sn)]], fields: ['ip_address'], limit: 1 }); if (rows && rows[0]) ip = rows[0].ip_address || '' } catch (e) { /* */ } }
+ 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: 'leases' }); if (sn) q.set('serial', sn); if (ip) q.set('ip', ip)
+ const r = await httpsGetJson(base + '?' + q.toString(), { 'X-Ops-Token': token }, 20000)
+ if (!r || r._err) return { ok: false, error: (r && r._err) || 'pont F injoignable' }
+ return r
+}
+
// 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()
@@ -1098,4 +1115,4 @@ function airosViaN8n ({ sn, ip, port, user } = {}) {
})
}
-module.exports = { handle, addComment, activity, createTicket, serviceStatus, dostuffStatus, fiberSignalFast, airosSignal, airosStatus, syncWirelessMacs, startWirelessMacSync, searchCustomers, fibreLive, syncFibreOnus }
+module.exports = { handle, addComment, activity, createTicket, serviceStatus, dostuffStatus, fiberSignalFast, airosSignal, airosStatus, airosLeases, syncWirelessMacs, startWirelessMacSync, searchCustomers, fibreLive, syncFibreOnus }