From 883546c85bff57e51d15618a1a82d6e9fe3dca0b Mon Sep 17 00:00:00 2001 From: louispaulb Date: Tue, 21 Jul 2026 14:48:09 -0400 Subject: [PATCH] feat(ops): dynamic status cards for device view (dumb-proof) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the device hero into a glanceable card grid — one card per key metric, plain-language hints, color = health. Driven by a merged statCards model (live OLT dostuff > GenieACS), so it works for both ACS-covered and tech-3 (no-ACS) ONUs; each card renders only when its data exists. Cards: Signal fibre (colored value + healthy-range meter + word: Bon/Faible/…), Uptime, IP publique, Appareils (Wi-Fi/filaire breakdown, click → device list), Réseau maillé (satellites), Gestion (GUI link), Téléphonie/VoIP, Télé/IPTV, Distance, Profil de ligne, Firmware, Dern. coupure. Replaces the old dense tiles in both the ACS topo card and the no-ACS fiber hero. Build clean, leak 0; deployed. Design previewed with the Imagine widget first. Co-Authored-By: Claude Opus 4.8 --- .../detail-sections/EquipmentDetail.vue | 83 +++++++++++++++---- 1 file changed, 67 insertions(+), 16 deletions(-) diff --git a/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue b/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue index f0a7cdf..e455877 100644 --- a/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue +++ b/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue @@ -19,11 +19,14 @@ -
-
Signal Rx/Tx
{{ device.rxPower }} / {{ device.txPower }} dBm
-
Uptime
{{ formatUptime(device.uptime) }}
- -
Dern. coupure
{{ lastOutage }}
+
+ +
{{ c.label }}
+
{{ c.value }}
+
+
{{ c.sub }}
+
@@ -85,17 +88,14 @@ Copier un résumé pour le ticket
-
-
Signal Rx/Tx
{{ liveF.rxPower }} / {{ liveF.txPower }} dBm
-
Distance
{{ liveF.distance }} m
-
Uptime
{{ liveF.uptime }}
- -
WAN
{{ liveF.wanIp }}
-
Profil
{{ liveF.profileId }}
-
VoIP
{{ liveF.voip }}
-
IPTV
{{ liveF.iptv }}
-
Firmware
{{ liveF.firmware }}
-
Dern. coupure
{{ liveF.lastDown }}
+
+ +
{{ c.label }}
+
{{ c.value }}
+
+
{{ c.sub }}
+
{{ liveF.error || 'OLT injoignable' }}
Lecture des données OLT en direct…
@@ -761,6 +761,46 @@ function wifiSigColor (s) { if (s == null) return '#64748b'; if (s >= -60) retur 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 +// Qualité fibre en MOT clair (dumb-proof) + position 0-100 sur la jauge (fort −8 → plein ; faible −28 → vide). +function fibreQualWord (rx) { if (rx == null) return ''; if (rx > -8) return 'Trop fort'; if (rx >= -25) return 'Bon'; if (rx >= -28) return 'Faible'; return 'Critique' } +function fibreMeterPct (rx) { if (rx == null) return 0; return Math.max(4, Math.min(100, Math.round(((rx + 30) / 22) * 100))) } +// Répartition Wi-Fi / filaire (depuis la liste d'appareils si chargée, sinon compte Wi-Fi ACS). +const clientsSub = computed(() => { + const h = hosts.value && hosts.value.hosts + if (h && h.length) { let w = 0, f = 0; for (const x of h) { if (x.isMeshNode) continue; if (x.band) w++; else f++ } return w + ' Wi-Fi · ' + f + ' filaire' } + const wc = device.value && device.value.wifi && device.value.wifi.totalClients + return wc != null ? (wc + ' Wi-Fi') : 'appareils connectés' +}) +// Cartes d'état DYNAMIQUES (dumb-proof) : signal, uptime, IP publique, appareils, mesh, gestion, VoIP, IPTV. +// Source fusionnée : direct OLT (liveF) > ACS (device). Une carte n'apparaît que si sa donnée existe. +const statCards = computed(() => { + const lf = (liveF.value && liveF.value.ok) ? liveF.value : null + const d = device.value || {} + const cards = [] + 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') }) + 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 + if (wan) cards.push({ key: 'wan', icon: 'public', label: 'IP publique', value: wan, mono: true, sub: 'adresse Internet du client' }) + const cc = clientCount.value + if (cc != null && (cc > 0 || d.wifi)) cards.push({ key: 'cli', icon: 'devices', label: 'Appareils', value: String(cc), sub: clientsSub.value, click: 'hosts' }) + const mesh = (d.mesh || []).length + if (mesh > 1) cards.push({ key: 'mesh', icon: 'hub', label: 'Réseau maillé', value: (mesh - 1) + ' satellite' + (mesh - 1 > 1 ? 's' : ''), color: '#16a34a', sub: d.mesh.filter(n => n.active).length + ' actif(s)' }) + const mgmt = (lf && lf.managementIp) || managementIp.value || (isWireless.value ? props.doc.ip_address : null) + if (mgmt) cards.push({ key: 'mgmt', icon: 'settings', label: 'Gestion', value: mgmt, mono: true, link: mgmtUrl(mgmt), sub: "interface de l'appareil" }) + if (lf && lf.voip) cards.push({ key: 'voip', icon: 'call', label: 'Téléphonie', value: 'Enregistrée', color: '#16a34a', sub: 'ligne VoIP active' }) + if (lf && lf.iptv) cards.push({ key: 'iptv', icon: 'live_tv', label: 'Télé', value: 'Connectée', color: '#16a34a', sub: 'décodeur IPTV en ligne' }) + if (lf && lf.distance != null) cards.push({ key: 'dist', icon: 'straighten', label: 'Distance', value: lf.distance + ' m', sub: 'longueur de fibre estimée' }) + if (lf && lf.profileId) cards.push({ key: 'prof', icon: 'speed', label: 'Profil de ligne', value: String(lf.profileId), sub: 'forfait OLT' }) + const fw = (lf && lf.firmware) || d.firmware || null + if (fw) cards.push({ key: 'fw', icon: 'memory', label: 'Firmware', value: fw, mono: true }) + const down = (lf && lf.lastDown) || lastOutage.value || null + if (down) cards.push({ key: 'down', icon: 'power_off', label: 'Dern. coupure', value: down, warn: true, sub: 'dernière raison hors ligne' }) + return cards +}) async function fetchLiveF () { const olt = props.doc.olt_ip @@ -1189,6 +1229,17 @@ onUnmounted(() => { stopWanLive(); stopEthLive() }) .topo-badges { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-top: 10px; } .topo-sub { font-size: 0.66rem; color: #94a3b8; } .dv-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-top: 10px; } +.stat-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 8px; margin-top: 10px; } +.stat-card { background: #f8fafc; border: 1px solid #eef2f7; border-radius: 10px; padding: 9px 11px; text-decoration: none; color: inherit; display: block; } +.stat-card.stat-click { cursor: pointer; transition: background .12s, border-color .12s; } +.stat-card.stat-click:hover { background: #eef2ff; border-color: #c7d2fe; } +.stat-lbl { font-size: 0.68rem; color: #64748b; display: flex; align-items: center; gap: 4px; font-weight: 600; text-transform: uppercase; letter-spacing: .3px; } +.stat-lbl.stat-warn { color: #b45309; } +.stat-val { font-size: 1.15rem; font-weight: 700; color: #1e293b; margin-top: 3px; font-variant-numeric: tabular-nums; line-height: 1.2; word-break: break-word; } +.stat-val.mono { font-family: monospace; font-size: 0.9rem; font-weight: 600; } +.stat-meter { height: 5px; border-radius: 3px; background: #e2e8f0; margin-top: 7px; overflow: hidden; } +.stat-meter-fill { height: 100%; border-radius: 3px; transition: width .4s ease; } +.stat-sub { font-size: 0.7rem; color: #64748b; margin-top: 5px; line-height: 1.3; } .dv-tile { border: 1px solid #e2e8f0; border-radius: 7px; padding: 6px 8px; background: #fff; } .dv-tl { font-size: 0.68rem; color: #64748b; display: flex; align-items: center; gap: 3px; } .dv-tl.dv-warn { color: #b45309; }