feat(ops): wireless on the same status cards + DHCP leases (F parity)

Unify the wireless (airOS) device view onto the shared statCards grid — signal
(colored + meter), signal côté borne, CCQ, débit lien TX/RX, capacité airMAX,
SSID, fréquence, uptime, gestion, point d'accès (link) — same glanceable cards as
fiber, replacing the old chip row.

"See what's happening" — the three things F shows, now in OPS:
- actual traffic: live eth0 sparkline (already present)
- DHCP leases = the devices behind the CPE: ops_airos.php mode=leases SSHes the CPE
  (port 2194) -> cat /tmp/dhcpd.leases -> {mac, ip, host, vendor(OUI), remaining};
  hub airosLeases + GET /collab/airos-leases; UI renders an on-demand "Appareils du
  client (DHCP)" table with refresh.
- wifi/wired clients = those leases.

Verified: leases for Bryson CPE -> Cisco-Linksys 172.31.1.19 (OUI vendor resolved),
through the hub too. Build clean, leak 0; deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-21 15:17:34 -04:00
parent 883546c85b
commit 80a059f50c
3 changed files with 101 additions and 15 deletions

View File

@ -160,18 +160,14 @@
<a v-if="doc.ip_address" :href="mgmtUrl(doc.ip_address)" target="_blank" class="dsf-open"><q-icon name="open_in_new" size="13px" /> Interface</a>
<q-btn dense flat no-caps size="sm" color="grey-7" icon="content_copy" label="Copier pour le ticket" @click="copyForTicket" />
</div>
<div v-if="airos && airos.ok" class="dsf-tiles">
<span v-if="airos.signal != null" class="dsf-chip dsf-sig" :style="{ color: wifiSigColor(airos.signal), borderColor: wifiSigColor(airos.signal) }"><q-icon name="network_check" size="12px" /> Signal {{ airos.signal }} dBm<span v-if="airos.signalAp != null" :style="{ color: wifiSigColor(airos.signalAp) }"> · AP {{ airos.signalAp }}</span></span>
<span v-if="airos.ccq != null" class="dsf-chip"><q-icon name="verified" size="12px" /> CCQ {{ airos.ccq }} %</span>
<span v-if="airos.txRate || airos.rxRate" class="dsf-chip"><q-icon name="swap_vert" size="12px" /> TX/RX {{ airos.txRate || '—' }} / {{ airos.rxRate || '—' }}</span>
<span v-if="airos.capDown != null || airos.capUp != null" class="dsf-chip"><q-icon name="speed" size="12px" /> airMAX {{ airos.capDown != null ? airos.capDown : '—' }} {{ airos.capUp != null ? airos.capUp : '—' }} Mbps</span>
<span v-if="airos.ssid" class="dsf-chip"><q-icon name="rss_feed" size="12px" /> {{ airos.ssid }}</span>
<span v-if="airos.frequency" class="dsf-chip"><q-icon name="settings_input_antenna" size="12px" /> {{ airos.frequency }}</span>
<span v-if="airos.txPower" class="dsf-chip"><q-icon name="bolt" size="12px" /> TX {{ airos.txPower }}</span>
<span v-if="airos.uptime" class="dsf-chip"><q-icon name="schedule" size="12px" /> {{ airos.uptime }}</span>
<span v-if="airos.version" class="dsf-chip"><q-icon name="memory" size="12px" /> {{ airos.version }}</span>
<a v-if="airos.apIp" :href="mgmtUrl(airos.apIp)" target="_blank" class="dsf-chip dsf-link"><q-icon name="cell_tower" size="12px" /> AP {{ airos.apIp }}</a>
<span v-else-if="airos.apMac" class="dsf-chip"><q-icon name="cell_tower" size="12px" /> AP {{ airos.apMac }}</span>
<div v-if="airos && airos.ok && statCards.length" class="stat-cards">
<component :is="c.link ? 'a' : 'div'" v-for="c in statCards" :key="c.key" class="stat-card" :class="{ 'stat-click': c.click || c.link }"
:href="c.link || undefined" :target="c.link ? '_blank' : undefined" @click="c.click === 'hosts' ? openHosts() : null">
<div class="stat-lbl" :class="{ 'stat-warn': c.warn }"><q-icon :name="c.icon" size="14px" /> {{ c.label }}</div>
<div class="stat-val" :class="{ mono: c.mono }" :style="c.color ? { color: c.color } : {}">{{ c.value }}</div>
<div v-if="c.meter != null" class="stat-meter"><div class="stat-meter-fill" :style="{ width: c.meter + '%', background: c.color }"></div></div>
<div v-if="c.sub" class="stat-sub">{{ c.sub }}</div>
</component>
</div>
<div v-else-if="airos && !airos.ok" class="dsf-note">
<q-icon name="info" size="12px" /> {{ airos.registered === false ? 'Signal sans-fil en attente : webhook n8n « get_signal_airos » à configurer.' : airos.error }}
@ -205,6 +201,26 @@
</template>
</template>
</div>
<!-- Appareils derrière le CPE (baux DHCP, façon F « Refresh Leases ») à la demande. « voir ce qui se passe ». -->
<div v-if="airos && airos.ok" class="dsf-eth">
<div class="dsf-eth-row">
<span class="dsf-eth-tl"><q-icon name="devices" size="13px" /> Appareils du client (DHCP){{ leases ? ' · ' + leases.length : '' }}</span>
<q-btn dense flat round size="9px" :icon="leases ? 'refresh' : 'download'" color="primary" :loading="leasesLoading" @click="loadLeases"><q-tooltip>Lister les appareils derrière le CPE (baux DHCP)</q-tooltip></q-btn>
</div>
<div v-if="leasesErr" class="dsf-note">{{ leasesErr }}</div>
<table v-else-if="leases && leases.length" class="lease-tbl">
<thead><tr><th>Appareil</th><th>IP</th><th>MAC</th><th>Bail</th></tr></thead>
<tbody>
<tr v-for="(l, i) in leases" :key="i">
<td>{{ l.host || l.vendor || '—' }}<div v-if="l.vendor && l.host" class="lease-sub">{{ l.vendor }}</div></td>
<td><code>{{ l.ip }}</code></td>
<td><code class="lease-mac">{{ l.mac }}</code></td>
<td>{{ fmtLeaseRemain(l.remaining_s) }}</td>
</tr>
</tbody>
</table>
<div v-else-if="leases" class="dsf-note" style="color:#64748b"><q-icon name="info" size="12px" /> Aucun appareil (aucun bail DHCP actif sur le CPE)</div>
</div>
</div>
<!-- Identité de l'appareil (repliée par défaut) -->
@ -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 daccè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 daccè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; }

View File

@ -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");

View File

@ -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 }