feat(ops): dynamic status cards for device view (dumb-proof)
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 <noreply@anthropic.com>
This commit is contained in:
parent
93588975ad
commit
883546c85b
|
|
@ -19,11 +19,14 @@
|
|||
<q-btn flat dense size="sm" icon="sync" :loading="refreshing" @click="doRefresh" title="Rafraîchir" />
|
||||
</div>
|
||||
|
||||
<div class="dv-grid">
|
||||
<div class="dv-tile" v-if="device.rxPower != null && device.rxPower !== 0"><div class="dv-tl"><q-icon name="network_check" size="14px" /> Signal Rx/Tx</div><div class="dv-tv" :style="{ color: rxColor }">{{ device.rxPower }}<span v-if="device.txPower != null"> / {{ device.txPower }}</span> dBm</div></div>
|
||||
<div class="dv-tile" v-if="device.uptime"><div class="dv-tl"><q-icon name="schedule" size="14px" /> Uptime</div><div class="dv-tv">{{ formatUptime(device.uptime) }}</div></div>
|
||||
<div class="dv-tile" v-if="managementIp"><div class="dv-tl"><q-icon name="settings" size="14px" /> Gestion</div><a :href="'https://' + managementIp + '/'" target="_blank" class="dv-tv dv-link">{{ managementIp }}</a></div>
|
||||
<div class="dv-tile" v-if="lastOutage"><div class="dv-tl dv-warn"><q-icon name="power_off" size="14px" /> Dern. coupure</div><div class="dv-tv">{{ lastOutage }}</div></div>
|
||||
<div 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 class="dv-row2">
|
||||
|
|
@ -85,17 +88,14 @@
|
|||
<q-btn flat dense no-caps size="sm" color="grey-7" icon="content_copy" label="Ticket" @click="copyForTicket"><q-tooltip>Copier un résumé pour le ticket</q-tooltip></q-btn>
|
||||
<q-btn flat dense round size="sm" icon="sync" :loading="liveFLoading" @click="fetchLiveF" title="Réinterroger l'OLT" />
|
||||
</div>
|
||||
<div v-if="liveF && liveF.ok" class="dv-grid">
|
||||
<div class="dv-tile" v-if="liveF.rxPower != null"><div class="dv-tl"><q-icon name="network_check" size="14px" /> Signal Rx/Tx</div><div class="dv-tv" :style="{ color: fibreRxColor(liveF.rxPower) }">{{ liveF.rxPower }}<span v-if="liveF.txPower != null"> / {{ liveF.txPower }}</span> dBm</div></div>
|
||||
<div class="dv-tile" v-if="liveF.distance != null"><div class="dv-tl"><q-icon name="straighten" size="14px" /> Distance</div><div class="dv-tv">{{ liveF.distance }} m</div></div>
|
||||
<div class="dv-tile" v-if="liveF.uptime"><div class="dv-tl"><q-icon name="schedule" size="14px" /> Uptime</div><div class="dv-tv">{{ liveF.uptime }}</div></div>
|
||||
<div class="dv-tile" v-if="liveF.managementIp"><div class="dv-tl"><q-icon name="settings" size="14px" /> Gestion</div><a :href="'https://' + liveF.managementIp + '/'" target="_blank" class="dv-tv dv-link">{{ liveF.managementIp }}</a></div>
|
||||
<div class="dv-tile" v-if="liveF.wanIp"><div class="dv-tl"><q-icon name="public" size="14px" /> WAN</div><div class="dv-tv">{{ liveF.wanIp }}</div></div>
|
||||
<div class="dv-tile" v-if="liveF.profileId"><div class="dv-tl"><q-icon name="speed" size="14px" /> Profil</div><div class="dv-tv">{{ liveF.profileId }}</div></div>
|
||||
<div class="dv-tile" v-if="liveF.voip"><div class="dv-tl"><q-icon name="call" size="14px" /> VoIP</div><div class="dv-tv">{{ liveF.voip }}</div></div>
|
||||
<div class="dv-tile" v-if="liveF.iptv"><div class="dv-tl"><q-icon name="live_tv" size="14px" /> IPTV</div><div class="dv-tv">{{ liveF.iptv }}</div></div>
|
||||
<div class="dv-tile" v-if="liveF.firmware"><div class="dv-tl"><q-icon name="memory" size="14px" /> Firmware</div><div class="dv-tv">{{ liveF.firmware }}</div></div>
|
||||
<div class="dv-tile" v-if="liveF.lastDown"><div class="dv-tl dv-warn"><q-icon name="power_off" size="14px" /> Dern. coupure</div><div class="dv-tv">{{ liveF.lastDown }}</div></div>
|
||||
<div v-if="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="!liveFLoading && liveF && !liveF.ok" class="dv-wan-ph"><q-icon name="info" size="16px" color="grey-5" /> {{ liveF.error || 'OLT injoignable' }}</div>
|
||||
<div v-else-if="liveFLoading && !liveF" class="dv-wan-ph"><q-icon name="cell_tower" size="16px" color="grey-4" /> Lecture des données OLT en direct…</div>
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user