feat(ops): wireless (airOS/Cambium) Do Stuff parity — n8n bridge + panel
Last F Do Stuff gap was wireless CPEs (signal, signal-AP, CCQ, TX/RX rate,
airMAX capacity). Empirically confirmed the hub cannot reach the CPE mgmt
network (all probes timed out) — so, exactly like the fiber TP-Link path,
this goes through n8n.
- hub: ticket-collab.airosSignal({serial|ip}) + GET /collab/airos-signal.
Resolves mgmt IP/login from Service Equipment, calls n8n webhook
get_signal_airos (mirror of F device_ajax/airos_ac_ajax.php: airOS auth +
status.cgi parse), normalizes signal/signalAp/ccq/tx-rx rate/airMAX
capacity/model/version/uptime. Degrades gracefully: 404 -> registered:false.
- ops: EquipmentDetail shows a "Do Stuff en direct (sans-fil)" panel for
wireless equipment (isWireless heuristic), rendering the same tiles + feeding
"Copier pour le ticket". Shows an "en attente" note until the webhook exists.
Verified: hub live returns {ok:false,registered:false} for a real CPE (SN-772);
SPA build clean, leak-check 0 secrets, deployed (index.c4ccc86f.js). The one
remaining hop is creating the n8n get_signal_airos workflow (spec in docs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
80fdb91591
commit
d10bb521f7
|
|
@ -97,6 +97,38 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Do Stuff SANS-FIL (airOS/Cambium) : signal / signal AP / CCQ / débit / capacité airMAX live du CPE, via le
|
||||
webhook n8n get_signal_airos (le hub ne joint pas les CPE) — parité avec le device_view sans-fil de F. -->
|
||||
<div v-if="isWireless" class="dsf dsf-wl">
|
||||
<div class="dsf-bar">
|
||||
<q-btn v-if="!airos && !airosLoading" dense flat no-caps size="sm" color="deep-purple-6" icon="wifi_tethering"
|
||||
label="Do Stuff en direct (sans-fil)" @click="fetchAiros">
|
||||
<q-tooltip>Interroge le CPE : signal, CCQ, débit, capacité airMAX — comme F</q-tooltip>
|
||||
</q-btn>
|
||||
<div v-if="airosLoading" class="text-caption text-deep-purple-6"><q-spinner-dots size="18px" /> Interrogation du CPE…</div>
|
||||
<template v-if="airos && !airosLoading">
|
||||
<span class="dsf-title"><q-icon :name="airos.ok ? 'wifi' : 'wifi_off'" :color="airos.ok ? 'green-6' : 'grey-5'" size="15px" /> {{ airos.ok ? (airos.model || 'CPE sans-fil') : 'Sans-fil' }}</span>
|
||||
<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 />
|
||||
<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.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>
|
||||
</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 }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Identité de l'appareil (repliée par défaut) -->
|
||||
<div class="collapse-head" @click="showIdentity = !showIdentity"><q-icon :name="showIdentity ? 'expand_more' : 'chevron_right'" size="18px" class="q-mr-xs" />Identité de l'appareil</div>
|
||||
<div v-show="showIdentity || !device" class="modal-field-grid">
|
||||
|
|
@ -567,6 +599,22 @@ const portCtx = ref(null)
|
|||
// ── Do Stuff en direct (F) : /collab/dostuff interroge l'OLT via n8n (VoIP/IPTV/distance/WAN/profil/firmware).
|
||||
// ~30 s, à la demande (charge l'OLT) — jamais auto à l'ouverture. Réservé tech-3 XGS-PON (sinon « OLT non reconnue »).
|
||||
const liveF = ref(null); const liveFLoading = ref(false)
|
||||
// Sans-fil (airOS/Cambium) : pas de fibre (olt_ip), et marque/modèle/type = radio. Le CPE n'est ni dans GenieACS ni
|
||||
// dans le poller OLT → signal live via webhook n8n (comme la fibre). Heuristique volontairement stricte (marque radio).
|
||||
const isWireless = computed(() => {
|
||||
const d = props.doc
|
||||
if (d.olt_ip) return false
|
||||
const s = ((d.brand || '') + ' ' + (d.model || '') + ' ' + (d.equipment_type || '')).toLowerCase()
|
||||
return /ubiquit|airmax|litebeam|nanostation|nanobridge|rocket|powerbeam|bullet|cambium|airos|\bap\b|sans.?fil|wireless/.test(s)
|
||||
})
|
||||
const airos = ref(null); const airosLoading = ref(false)
|
||||
async function fetchAiros () {
|
||||
airos.value = null; airosLoading.value = true
|
||||
try {
|
||||
const r = await fetch(`${HUB_URL}/collab/airos-signal?serial=${encodeURIComponent(props.doc.serial_number || '')}&ip=${encodeURIComponent(props.doc.ip_address || '')}`)
|
||||
airos.value = await r.json()
|
||||
} catch (e) { airos.value = { ok: false, error: e.message } } finally { airosLoading.value = false }
|
||||
}
|
||||
async function fetchLiveF () {
|
||||
const sn = props.doc.serial_number, olt = props.doc.olt_ip
|
||||
if (!sn || !olt) return
|
||||
|
|
@ -603,6 +651,14 @@ function copyForTicket () {
|
|||
const fw = (lf && lf.firmware) || dev.firmware
|
||||
if (fw) L.push('Firmware : ' + fw)
|
||||
if (dev.uptime) L.push('Uptime : ' + formatUptime(dev.uptime))
|
||||
const a = airos.value
|
||||
if (a && a.ok) {
|
||||
if (a.model) L.push('CPE sans-fil : ' + a.model + (a.version ? ' (' + a.version + ')' : ''))
|
||||
if (a.signal != null) L.push('Signal : ' + a.signal + ' dBm' + (a.signalAp != null ? ' (AP ' + a.signalAp + ')' : ''))
|
||||
if (a.ccq != null) L.push('CCQ : ' + a.ccq + ' %')
|
||||
if (a.txRate || a.rxRate) L.push('Débit TX/RX : ' + (a.txRate || '—') + ' / ' + (a.rxRate || '—'))
|
||||
if (a.capDown != null || a.capUp != null) L.push('Capacité airMAX : DL ' + (a.capDown != null ? a.capDown : '—') + ' / UL ' + (a.capUp != null ? a.capUp : '—') + ' Mbps')
|
||||
}
|
||||
const txt = L.join('\n')
|
||||
const done = () => $q.notify({ type: 'positive', message: 'Résumé copié — collable dans un ticket', timeout: 1500 })
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(txt).then(done).catch(() => fallbackCopy(txt, done))
|
||||
|
|
@ -997,6 +1053,7 @@ onUnmounted(() => stopWanLive())
|
|||
.dsf-tiles { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }
|
||||
.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-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; }
|
||||
|
|
|
|||
|
|
@ -682,6 +682,11 @@ async function handle (req, res, method, p, url) {
|
|||
if (p === '/collab/dostuff' && method === 'GET') {
|
||||
return json(res, 200, await dostuffStatus({ sn: url.searchParams.get('serial') || url.searchParams.get('sn') || '', olt: url.searchParams.get('olt') || '' }))
|
||||
}
|
||||
// GET /collab/airos-signal?serial=<sn>[&ip=&port=&user=] — signal LIVE d'un CPE SANS-FIL (airOS/Cambium) via webhook n8n
|
||||
// (le hub ne joint PAS les CPE directement → pont n8n, comme la fibre TP-Link). 404 = webhook pas encore créé.
|
||||
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/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'))
|
||||
|
|
@ -797,4 +802,49 @@ function dostuffStatus ({ sn, olt } = {}) {
|
|||
})
|
||||
}
|
||||
|
||||
module.exports = { handle, addComment, activity, createTicket, serviceStatus, dostuffStatus }
|
||||
// ── airOS/Cambium (sans-fil fixe) : signal LIVE d'un CPE via un webhook n8n `get_signal_airos` (À CRÉER — voir la
|
||||
// fiche de mise en place n8n). Miroir de F `device_ajax/airos_ac_ajax.php` : n8n se logue sur le CPE (POST /api/auth
|
||||
// airOS ≥ 8.5, sinon /login.cgi), lit `status.cgi`, et renvoie signal/CCQ/débit/capacité airMAX. Le hub NE PEUT PAS
|
||||
// joindre les CPE directement (réseau terrain non routable — vérifié : timeouts) → d'où le pont n8n, exactement
|
||||
// comme la fibre TP-Link (/webhook/dostuff). 404 = webhook pas encore configuré → on dégrade proprement.
|
||||
async function airosSignal ({ sn, ip, port, user } = {}) {
|
||||
// Résout l'IP de gestion + le login depuis Service Equipment si non fournis (source unique du parc).
|
||||
if ((!ip || !user) && sn) {
|
||||
try {
|
||||
const rows = await erp.list('Service Equipment', { filters: [['serial_number', '=', String(sn)]], fields: ['ip_address', 'login_user'], limit: 1 })
|
||||
const e = rows && rows[0]
|
||||
if (e) { if (!ip) ip = e.ip_address || ''; if (!user) user = e.login_user || '' }
|
||||
} catch (e) { /* résolution best-effort */ }
|
||||
}
|
||||
if (!ip) return { ok: false, error: 'IP de gestion introuvable pour cet appareil (Service Equipment.ip_address vide)' }
|
||||
return new Promise((resolve) => {
|
||||
let https; try { https = require('https') } catch (e) { return resolve({ ok: false, error: 'https indisponible' }) }
|
||||
const body = JSON.stringify({ sn: String(sn || ''), ip: String(ip), port: Number(port) || 2196, user: String(user || 'admin') })
|
||||
const req = https.request('https://n8napi.targo.ca/webhook/get_signal_airos', { method: 'GET', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 25000 }, (res) => {
|
||||
let d = ''; res.on('data', c => { d += c }); res.on('end', () => {
|
||||
if (res.statusCode === 404) return resolve({ ok: false, registered: false, error: 'Webhook n8n « get_signal_airos » non configuré' })
|
||||
let arr; try { arr = JSON.parse(d) } catch (e) { return resolve({ ok: false, error: 'réponse n8n illisible' }) }
|
||||
const r = Array.isArray(arr) ? arr[0] : arr
|
||||
if (!r || r.ErrorCode || r.error) return resolve({ ok: false, error: (r && (r.ErrorCode || r.error)) || 'réponse vide (CPE injoignable ?)' })
|
||||
const clean = (v) => String(v == null ? '' : v).replace(/^"+|"+$/g, '').trim()
|
||||
const num = (v) => { const n = parseFloat(clean(v)); return isNaN(n) ? null : n }
|
||||
resolve({
|
||||
ok: true, source: 'airos',
|
||||
model: clean(r.device_model || r.model) || null, name: clean(r.device_name || r.name) || null,
|
||||
version: clean(r.version) || null, ssid: clean(r.ssid) || null,
|
||||
frequency: clean(r.frequency) || null, txPower: clean(r.tx_power || r.txPower) || null, apMac: clean(r.mac_ap || r.apMac) || null,
|
||||
signal: num(r.signal), signalAp: num(r.signal_ap || r.signalAp), ccq: num(r.ccq),
|
||||
txRate: clean(r.tx_rate || r.txRate) || null, rxRate: clean(r.rx_rate || r.rxRate) || null,
|
||||
capUp: num(r.airmax_capacity_uplink || r.capUp), capDown: num(r.airmax_capacity_downlink || r.capDown),
|
||||
uptime: clean(r.connection_time || r.uptime) || null,
|
||||
lan0: clean(r.if_speed_lan0 || r.lan0) || null, lan1: clean(r.if_speed_lan1 || r.lan1) || null,
|
||||
})
|
||||
})
|
||||
})
|
||||
req.on('error', e => resolve({ ok: false, error: e.message }))
|
||||
req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'délai dépassé (~25 s) — CPE lent ou hors ligne' }) })
|
||||
req.write(body); req.end()
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { handle, addComment, activity, createTicket, serviceStatus, dostuffStatus, airosSignal }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user