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:
parent
0ec7a8cbba
commit
60d3f73623
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
80
services/targo-hub/scripts/backfill-wireless-equip.js
Normal file
80
services/targo-hub/scripts/backfill-wireless-equip.js
Normal 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 device→service→delivery_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) })
|
||||
Loading…
Reference in New Issue
Block a user