Standardize the device-ops entry so users coming from F aren't lost: - New shared DoStuffButton.vue (canonical "Do Stuff" label + icon): resolves the device by serial>service_location>customer and opens the ONE device view (EquipmentDetail). Reused in SubscriptionDetail (replaces bespoke button). - DeviceStrip (fiche): tooltip hint + right-click "Do Stuff — ouvrir l'appareil" (works even for devices without ACS data, e.g. wireless), so the term is discoverable on the customer card too. Bring the canonical device view to F's function set (device_view.php): - On-demand "Do Stuff en direct (F)" fetch (/collab/dostuff) surfaces the fields the passive poller lacks: VoIP, IPTV, distance, WAN IP, line profile, firmware, mgmt GUI — reserved tech-3 XGS-PON, ~30 s, never auto (OLT load). - "Copier pour le ticket" (F's copy button): plaintext device summary to clipboard. Verified: SPA build clean, leak-check 0 secrets, deployed to /opt/ops-app (index.969e330c.js live, 303508 B served, nginx.conf preserved). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
172 lines
9.5 KiB
Vue
172 lines
9.5 KiB
Vue
<template>
|
|
<div class="device-strip">
|
|
<div v-for="eq in equipment" :key="eq.name" class="device-icon-chip" :class="deviceColorClass(eq.status)"
|
|
@click="$emit('open-device', eq)">
|
|
<q-icon :name="deviceLucideIcon(eq.equipment_type)" size="20px" />
|
|
<span v-if="isOnline(eq.serial_number) === true" class="acs-dot acs-online" />
|
|
<span v-else-if="isOnline(eq.serial_number) === false" class="acs-dot acs-offline" />
|
|
<span v-else-if="eq.serial_number && !getDevice(eq.serial_number)" class="acs-dot acs-probing" />
|
|
<q-tooltip class="bg-grey-9 text-caption" :offset="[0, 6]" style="max-width:320px">
|
|
<div><strong>{{ eq.equipment_type }}{{ eq.brand ? ' — ' + eq.brand : '' }}{{ eq.model ? ' ' + eq.model : '' }}</strong></div>
|
|
<div>SN: {{ eq.serial_number }}</div>
|
|
<template v-if="eq.mac_address"><div>MAC: {{ eq.mac_address }}</div></template>
|
|
<template v-if="eq.olt_name"><div>OLT: {{ eq.olt_name }} — Slot {{ eq.olt_slot }}/Port {{ eq.olt_port }}/ONT {{ eq.olt_ontid }}</div></template>
|
|
<template v-else-if="eq.ip_address"><div>IP: {{ eq.ip_address }}</div></template>
|
|
<div>{{ eq.status }}</div>
|
|
<template v-if="getDevice(eq.serial_number) || combinedStatus(eq.serial_number).source !== 'unknown'">
|
|
<q-separator dark class="q-my-xs" />
|
|
<div :style="{ color: combinedStatus(eq.serial_number).online ? '#4ade80' : '#f87171' }">
|
|
{{ combinedStatus(eq.serial_number).online ? '● En ligne' : '● Hors ligne' }}
|
|
<template v-if="getDevice(eq.serial_number)?.lastInform"> — {{ formatTimeAgo(getDevice(eq.serial_number).lastInform) }}</template>
|
|
</div>
|
|
<div class="text-grey-4" style="font-size:0.7rem">{{ combinedStatus(eq.serial_number).detail }}</div>
|
|
<template v-if="getDevice(eq.serial_number)?.opticalStatus">
|
|
<div>Fibre: <span :style="{ color: getDevice(eq.serial_number).opticalStatus === 'Up' ? '#4ade80' : '#f87171' }">{{ getDevice(eq.serial_number).opticalStatus }}</span></div>
|
|
</template>
|
|
<template v-if="getDevice(eq.serial_number).rxPower != null && getDevice(eq.serial_number).rxPower !== 0">
|
|
<div :style="{ color: signalColor(eq.serial_number) }">
|
|
Rx: {{ getDevice(eq.serial_number).rxPower }} dBm
|
|
<template v-if="getDevice(eq.serial_number).txPower != null && getDevice(eq.serial_number).txPower !== 0"> / Tx: {{ getDevice(eq.serial_number).txPower }} dBm</template>
|
|
</div>
|
|
</template>
|
|
<template v-if="getDevice(eq.serial_number).wifi">
|
|
<div v-if="getDevice(eq.serial_number).wifi.totalClients != null || getDevice(eq.serial_number).wifi.radio1?.clients != null">
|
|
WiFi: {{ getDevice(eq.serial_number).wifi.totalClients ?? ((getDevice(eq.serial_number).wifi.radio1?.clients || 0) + (getDevice(eq.serial_number).wifi.radio2?.clients || 0) + (getDevice(eq.serial_number).wifi.radio3?.clients || 0)) }} clients
|
|
<span v-if="getDevice(eq.serial_number).wifi.meshClients > 0">({{ getDevice(eq.serial_number).wifi.meshClients }} via mesh)</span>
|
|
</div>
|
|
</template>
|
|
<template v-if="getDevice(eq.serial_number).firmware"><div>FW: {{ getDevice(eq.serial_number).firmware }}</div></template>
|
|
<template v-if="getDevice(eq.serial_number).ip"><div>WAN IP: {{ getDevice(eq.serial_number).ip }}</div></template>
|
|
<template v-if="getDevice(eq.serial_number).ssid"><div>SSID: {{ getDevice(eq.serial_number).ssid }}</div></template>
|
|
</template>
|
|
<q-separator dark class="q-my-xs" />
|
|
<div class="text-grey-4" style="font-size:0.7rem"><q-icon name="settings_input_antenna" size="11px" /> Clic : Do Stuff — signal, actions, VoIP/IPTV</div>
|
|
</q-tooltip>
|
|
<!-- Do Stuff toujours accessible (clic gauche ou ce menu) — même pour un appareil sans données ACS (sans-fil). -->
|
|
<q-menu context-menu>
|
|
<q-list dense style="min-width:180px">
|
|
<q-item clickable v-close-popup @click="$emit('open-device', eq)">
|
|
<q-item-section avatar><q-icon name="settings_input_antenna" size="18px" color="deep-purple-6" /></q-item-section>
|
|
<q-item-section>Do Stuff — ouvrir l'appareil</q-item-section>
|
|
</q-item>
|
|
<template v-if="getDevice(eq.serial_number)">
|
|
<q-separator />
|
|
<q-item clickable v-close-popup @click="doReboot(eq)">
|
|
<q-item-section avatar><q-icon name="restart_alt" size="18px" /></q-item-section>
|
|
<q-item-section>Redémarrer</q-item-section>
|
|
</q-item>
|
|
<q-item clickable v-close-popup @click="doRefreshParams(eq)">
|
|
<q-item-section avatar><q-icon name="sync" size="18px" /></q-item-section>
|
|
<q-item-section>Rafraîchir params</q-item-section>
|
|
</q-item>
|
|
</template>
|
|
</q-list>
|
|
</q-menu>
|
|
</div>
|
|
<div class="device-icon-chip device-add-chip">
|
|
<q-icon name="add" size="20px" />
|
|
<q-tooltip>Ajouter</q-tooltip>
|
|
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
|
|
<q-list dense style="min-width:240px">
|
|
<q-item clickable v-close-popup @click="$emit('add-submission')">
|
|
<q-item-section avatar><q-icon name="description" size="18px" color="primary" /></q-item-section>
|
|
<q-item-section>
|
|
<q-item-label>Nouvelle soumission</q-item-label>
|
|
<q-item-label caption>Wizard avec étapes dispatchables</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
<q-separator />
|
|
<q-item clickable v-close-popup @click="$emit('add-service')">
|
|
<q-item-section avatar><q-icon name="wifi" size="18px" color="info" /></q-item-section>
|
|
<q-item-section>Forfait / Service (direct)</q-item-section>
|
|
</q-item>
|
|
<q-item clickable v-close-popup @click="$emit('add-rebate')">
|
|
<q-item-section avatar><q-icon name="sell" size="18px" color="negative" /></q-item-section>
|
|
<q-item-section>Rabais / Crédit</q-item-section>
|
|
</q-item>
|
|
<q-separator />
|
|
<q-item clickable v-close-popup @click="$emit('add-equipment')">
|
|
<q-item-section avatar><q-icon name="router" size="18px" color="positive" /></q-item-section>
|
|
<q-item-section>Équipement</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</q-menu>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { Notify } from 'quasar'
|
|
import { deviceColorClass } from 'src/composables/useStatusClasses'
|
|
import { deviceLucideIcon } from 'src/config/device-icons'
|
|
import { useDeviceStatus } from 'src/composables/useDeviceStatus'
|
|
|
|
defineProps({
|
|
equipment: { type: Array, default: () => [] },
|
|
})
|
|
defineEmits(['open-device', 'add-submission', 'add-service', 'add-rebate', 'add-equipment'])
|
|
|
|
// useDeviceStatus is a singleton composable — shares the live GenieACS/OLT
|
|
// device map that ClientDetailPage populates via fetchStatus/fetchOltStatus.
|
|
const { getDevice, isOnline, combinedStatus, signalQuality, rebootDevice, refreshDeviceParams, fetchStatus } = useDeviceStatus()
|
|
|
|
// ── Device-only presentation/action helpers (used nowhere else) ──
|
|
function signalColor (serial) {
|
|
const q = signalQuality(serial)
|
|
return q === 'excellent' ? '#4ade80' : q === 'good' ? '#a3e635' : q === 'fair' ? '#fbbf24' : '#f87171'
|
|
}
|
|
|
|
function formatTimeAgo (dateStr) {
|
|
if (!dateStr) return ''
|
|
const diff = Date.now() - new Date(dateStr).getTime()
|
|
const mins = Math.floor(diff / 60000)
|
|
if (mins < 1) return 'à l\'instant'
|
|
if (mins < 60) return `il y a ${mins}m`
|
|
const hrs = Math.floor(mins / 60)
|
|
return hrs < 24 ? `il y a ${hrs}h` : `il y a ${Math.floor(hrs / 24)}j`
|
|
}
|
|
|
|
async function doReboot (eq) {
|
|
try {
|
|
await rebootDevice(eq.serial_number)
|
|
Notify.create({ type: 'positive', message: `Redémarrage envoyé: ${eq.serial_number}`, position: 'top' })
|
|
} catch (e) {
|
|
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
|
|
}
|
|
}
|
|
|
|
async function doRefreshParams (eq) {
|
|
try {
|
|
await refreshDeviceParams(eq.serial_number)
|
|
Notify.create({ type: 'info', message: `Rafraîchissement lancé: ${eq.serial_number}`, position: 'top' })
|
|
setTimeout(() => fetchStatus([eq]), 3000)
|
|
} catch (e) {
|
|
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.device-strip { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
.device-icon-chip {
|
|
display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 8px; cursor: pointer; transition: transform 0.15s; position: relative;
|
|
&:hover { transform: scale(1.1); }
|
|
&.small { width: 28px; height: 28px; border-radius: 6px; flex-shrink: 0; }
|
|
&.dev-green { background: #dcfce7; color: #16a34a; }
|
|
&.dev-red { background: #fee2e2; color: #dc2626; }
|
|
&.dev-grey { background: #f1f5f9; color: #94a3b8; }
|
|
}
|
|
.acs-dot { position: absolute; top: 2px; right: 2px; width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid #fff; }
|
|
.acs-online { background: #22c55e; box-shadow: 0 0 4px #22c55e88; }
|
|
.acs-offline { background: #ef4444; box-shadow: 0 0 4px #ef444488; }
|
|
.acs-probing { background: #f59e0b; animation: acs-pulse 1.2s ease-in-out infinite; }
|
|
@keyframes acs-pulse {
|
|
0%, 100% { opacity: 1; box-shadow: 0 0 4px #f59e0b88; }
|
|
50% { opacity: 0.3; box-shadow: 0 0 8px #f59e0bcc; }
|
|
}
|
|
.device-add-chip {
|
|
background: #f1f5f9; color: #94a3b8; border: 2px dashed #cbd5e1;
|
|
&:hover { border-color: #6366f1; color: #6366f1; background: #eef2ff; }
|
|
}
|
|
</style>
|