feat(ops): wireless backfill + signal interval coloring + device/AP links

Backfill (scripts/backfill-wireless-equip.js): mirror F wireless CPEs
(airos_ac/airosm/cambium) into OPS Service Equipment, resolving customer +
service_location via device→service→delivery_id→Service Location(legacy_delivery_id),
dedup by MAC-or-IP, serial=MAC when F sn is null, undo manifest. Park was already
~99% synced — created the 2 genuinely-missing (incl. James A. Bryson) + flagged
devices without service/MAC. Verified: Bryson EQP-0000100001 → live LiteBeam 5AC
signal -74/-72, airMAX 93/67.

Signal interval coloring (F parity): wifiSigColor (airOS: >=-60 green … <-78 red)
and fibreRxColor (Rx -8..-25 green, -25..-28 orange, else red) color the live
signal tiles (wireless + fibre). Assistant analysis (check_signal advice) kept.

Device + AP links in service details: CPE management GUI link (Interface →
https://ip) in the wireless panel; AP link when its mgmt IP resolves (ops_airos.php
resolves the AP by radio MAC in F's device table, best-effort → apIp/ap_ip), else
shows the AP MAC. Hub normalizeAiros passes apIp through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-20 18:38:33 -04:00
parent 0ec7a8cbba
commit 60d3f73623
4 changed files with 105 additions and 3 deletions

View File

@ -84,7 +84,7 @@
</q-btn>
</div>
<div v-if="liveF && liveF.ok" class="dsf-tiles">
<span v-if="liveF.rxPower != null" class="dsf-chip"><q-icon name="network_check" size="12px" /> {{ liveF.signal }} · Rx {{ liveF.rxPower }}<span v-if="liveF.txPower != null"> / Tx {{ liveF.txPower }}</span> dBm</span>
<span v-if="liveF.rxPower != null" class="dsf-chip dsf-sig" :style="{ color: fibreRxColor(liveF.rxPower), borderColor: fibreRxColor(liveF.rxPower) }"><q-icon name="network_check" size="12px" /> {{ liveF.signal }} · Rx {{ liveF.rxPower }}<span v-if="liveF.txPower != null"> / Tx {{ liveF.txPower }}</span> dBm</span>
<span v-if="liveF.distance != null" class="dsf-chip"><q-icon name="straighten" size="12px" /> {{ liveF.distance }} m</span>
<span v-if="liveF.voip" class="dsf-chip"><q-icon name="call" size="12px" /> VoIP {{ liveF.voip }}</span>
<span v-if="liveF.iptv" class="dsf-chip"><q-icon name="live_tv" size="12px" /> IPTV {{ liveF.iptv }}</span>
@ -111,10 +111,11 @@
<q-btn dense flat round size="xs" icon="refresh" color="grey-6" @click="fetchAiros"><q-tooltip>Réinterroger</q-tooltip></q-btn>
</template>
<q-space />
<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"><q-icon name="network_check" size="12px" /> Signal {{ airos.signal }} dBm<span v-if="airos.signalAp != null"> · AP {{ airos.signalAp }}</span></span>
<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>
@ -123,6 +124,8 @@
<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>
<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 }}
@ -615,6 +618,13 @@ async function fetchAiros () {
airos.value = await r.json()
} catch (e) { airos.value = { ok: false, error: e.message } } finally { airosLoading.value = false }
}
// Coloration du signal par INTERVALLE (parité F). Sans-fil (dBm airOS) : -60 bon, -70 correct, -78 faible, < critique.
function wifiSigColor (s) { if (s == null) return '#64748b'; if (s >= -60) return '#16a34a'; if (s >= -70) return '#65a30d'; if (s >= -78) return '#d97706'; return '#dc2626' }
// Fibre (Rx optique dBm) : plage saine -8..-25 vert ; -25..-28 orange ; < -28 (ou > -8 sur-puissance) rouge.
function fibreRxColor (rx) { if (rx == null) return '#64748b'; if (rx <= -8 && rx >= -25) return '#16a34a'; if (rx < -28) return '#dc2626'; return '#d97706' }
// Port de gestion airOS : F expose souvent l'API sur 2196, mais l'interface web répond en 443 on lie la racine.
const mgmtUrl = (ip) => ip ? ('https://' + String(ip).replace(/:\d+$/, '') + '/') : null
async function fetchLiveF () {
const sn = props.doc.serial_number, olt = props.doc.olt_ip
if (!sn || !olt) return
@ -1054,6 +1064,9 @@ onUnmounted(() => stopWanLive())
.dsf-chip { display: inline-flex; align-items: center; gap: 3px; font-size: 0.74rem; color: #334155; background: #fff; border: 1px solid #e2e8f0; border-radius: 5px; padding: 2px 7px; }
.dsf-chip.dsf-warn { background: #fffbeb; border-color: #fde68a; color: #92400e; }
.dsf-note { font-size: 0.74rem; color: #92400e; margin-top: 6px; display: flex; align-items: center; gap: 4px; }
.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; }
.dsf-link { color: #1e40af; text-decoration: none; }
.dsf-link:hover { text-decoration: underline; }
.collapse-head { display: flex; align-items: center; cursor: pointer; font-size: 0.8rem; font-weight: 600; color: #475569; padding: 6px 2px; border-top: 1px solid #f1f5f9; user-select: none; }

View File

@ -128,6 +128,14 @@ $signal = $sta['signal'] ?? ($w['signal'] ?? null);
$ccq = $sta['ccq'] ?? ($w['ccq'] ?? null);
if ($ccq !== null && (!is_numeric($ccq) || $ccq < 0 || $ccq > 100)) $ccq = null;
// IP de gestion de l'AP (borne) : résous la MAC radio de l'AP → ligne device correspondante (best-effort ; permet
// un lien direct vers l'AP dans OPS). La MAC radio ≠ toujours device.mac → si non trouvé, ap_ip reste null.
$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) { $macNoColon = str_replace(array(':', '-'), '', $apmac); $st->bind_param('ss', $apmac, $macNoColon); $st->execute(); $r2 = $st->get_result()->fetch_assoc(); if ($r2) $ap_ip = $r2['manage']; }
}
out(array(
'ok' => true, 'source' => 'f',
'device_model' => $host['devmodel'] ?? null,
@ -137,6 +145,7 @@ out(array(
'frequency' => $w['frequency'] ?? null,
'tx_power' => $w['txpower'] ?? null,
'mac_ap' => $w['apmac'] ?? null,
'ap_ip' => $ap_ip,
'signal' => $signal,
'signal_ap' => $sta['remote']['signal'] ?? null,
'ccq' => $ccq,

View File

@ -814,7 +814,7 @@ function normalizeAiros (r) {
return {
ok: true, source: r.source || 'airos',
model: clean(r.device_model || r.model), name: clean(r.device_name || r.name), version: clean(r.version),
ssid: clean(r.ssid), frequency: clean(r.frequency), txPower: clean(r.tx_power || r.txPower), apMac: clean(r.mac_ap || r.apMac),
ssid: clean(r.ssid), frequency: clean(r.frequency), txPower: clean(r.tx_power || r.txPower), apMac: clean(r.mac_ap || r.apMac), apIp: clean(r.ap_ip || r.apIp),
signal: num(r.signal), signalAp: num(r.signal_ap || r.signalAp), ccq: num(r.ccq),
txRate: clean(r.tx_rate || r.txRate), rxRate: clean(r.rx_rate || r.rxRate),
capUp: num(r.airmax_capacity_uplink || r.capUp), capDown: num(r.airmax_capacity_downlink || r.capDown),

View File

@ -0,0 +1,80 @@
/* backfill-wireless-equip.js crée les Service Equipment OPS manquants pour les CPE SANS-FIL de F
* (airos_ac / airosm / cambium). Résout customer+service_location via deviceservicedelivery_id
* Service Location(legacy_delivery_id). NON DESTRUCTIF (crée seulement), dédup par MAC/IP, manifeste d'annulation.
* Usage: node backfill-wireless-equip.js (DRY-RUN compte + échantillon, 0 écriture)
* node backfill-wireless-equip.js --apply (crée + écrit le manifeste)
*/
const erp = require('/app/lib/erp')
const mysql = require('mysql2/promise')
const fs = require('fs')
const APPLY = process.argv.includes('--apply')
const MANIFEST = '/app/data/backfill_wireless_equip.json'
const normMac = m => String(m || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase()
const brandOf = (cat, model) => /cambium/i.test(cat) ? 'Cambium' : (/ubiqu|nano|rocket|litebeam|powerbeam|bullet|airmax|airos|loco/i.test((model || '') + cat) ? 'Ubiquiti' : '')
;(async () => {
let cfg = {}; try { cfg = require('/app/lib/config') } catch (e) { /* fallback to env below */ }
const pool = mysql.createPool({
host: cfg.LEGACY_DB_HOST || process.env.LEGACY_DB_HOST || '10.100.80.100',
user: cfg.LEGACY_DB_USER || process.env.LEGACY_DB_USER || 'facturation',
password: cfg.LEGACY_DB_PASS || process.env.LEGACY_DB_PASS,
database: cfg.LEGACY_DB_NAME || process.env.LEGACY_DB_NAME || 'gestionclient',
connectionLimit: 2, connectTimeout: 8000,
})
// 1) CPE sans-fil F + delivery_id du service (préfère un service actif).
const [rows] = await pool.query(
"SELECT d.id device_id, d.category, d.manage, d.port, d.mac, d.sn, d.model, " +
" (SELECT s.delivery_id FROM service s WHERE s.device_id=d.id AND s.delivery_id>0 ORDER BY (s.status=1) DESC, s.id DESC LIMIT 1) delivery_id " +
" FROM device d WHERE d.category IN ('airos_ac','airosm','cambium') AND d.manage<>''")
await pool.end()
const wired = rows.filter(r => Number(r.delivery_id) > 0)
// 2) Index Service Location OPS par legacy_delivery_id → {name, customer}
const delIds = [...new Set(wired.map(r => Number(r.delivery_id)))]
const slIndex = {}
for (let i = 0; i < delIds.length; i += 100) {
const batch = delIds.slice(i, i + 100)
const sls = await erp.list('Service Location', { filters: [['legacy_delivery_id', 'in', batch]], fields: ['name', 'customer', 'legacy_delivery_id'], limit: 500 }).catch(() => [])
for (const s of sls) if (s.customer) slIndex[Number(s.legacy_delivery_id)] = { name: s.name, customer: s.customer }
}
// 3) Service Equipment existants (ceux avec ip_address = univers sans-fil) → dédup par MAC + IP
const existMac = new Set(), existIp = new Set()
const existing = await erp.list('Service Equipment', { filters: [['ip_address', '!=', '']], fields: ['name', 'mac_address', 'ip_address'], limit: 8000 }).catch(() => [])
for (const e of existing) { if (e.mac_address) existMac.add(normMac(e.mac_address)); if (e.ip_address) existIp.add(String(e.ip_address).trim()) }
// 4) calcule les créations
const toCreate = [], skips = { no_sl: 0, dup: 0, no_key: 0 }
for (const r of wired) {
const sl = slIndex[Number(r.delivery_id)]
if (!sl) { skips.no_sl++; continue }
const nm = normMac(r.mac), ip = String(r.manage).trim()
if (!nm && !ip) { skips.no_key++; continue } // ni MAC ni IP → indédupable, on saute
if ((nm && existMac.has(nm)) || (ip && existIp.has(ip))) { skips.dup++; continue }
// serial_number OBLIGATOIRE. F sn souvent NULL pour les CPE sans-fil → repli sur la MAC (identifiant réel, unique),
// sinon clé synthétique stable par device_id. mac_address garde le format d'origine (avec séparateurs).
const serial = (r.sn && String(r.sn).trim()) ? String(r.sn).trim() : (nm || ('AIROS-' + r.device_id))
const doc = {
equipment_type: 'Autre', brand: brandOf(r.category, r.model), model: r.model || '',
serial_number: serial, mac_address: r.mac || '', ip_address: r.manage, login_user: 'admin',
customer: sl.customer, service_location: sl.name, status: 'Actif', ownership: 'Gigafibre',
}
toCreate.push({ device_id: r.device_id, mac: r.mac, ip: r.manage, doc })
}
console.log(JSON.stringify({ f_wireless: rows.length, with_delivery: wired.length, existing_wireless_se: existing.length, to_create: toCreate.length, skipped: skips }, null, 1))
console.log('SAMPLE:', JSON.stringify(toCreate.slice(0, 4).map(x => x.doc), null, 1))
if (!APPLY) { console.log('DRY-RUN — passe --apply pour créer.'); return }
// 5) crée + manifeste
const created = []
let n = 0
for (const c of toCreate) {
const res = await erp.create('Service Equipment', c.doc).catch(e => ({ error: String(e.message || e) }))
const okName = (res && typeof res.name === 'string') ? res.name : null
if (okName) created.push({ name: okName, device_id: c.device_id, mac: c.mac, ip: c.ip })
else created.push({ error: String((res && res.error) || 'échec').slice(0, 160), status: res && res.status, device_id: c.device_id, mac: c.mac })
if (++n % 100 === 0) console.log('… ' + n + '/' + toCreate.length)
}
fs.writeFileSync(MANIFEST, JSON.stringify({ at: Date.now(), created }, null, 1))
console.log('CREATED ' + created.filter(c => c.name).length + ' / ' + toCreate.length + ' · errors ' + created.filter(c => c.error).length + ' · manifest ' + MANIFEST)
})().catch(e => { console.log('FATAL', e.message) })