NetworkPage débordait de ~118px à 375px. Deux causes corrigées :
- Barre d'onglets (4 onglets = 435px) non bornée dans une rangée flex → ne
déclenchait pas le défilement natif de Quasar. Ajout global `.q-tabs { max-width:100% }`
en mobile (app.scss) : Quasar réactive son scroll (aucun effet là où déjà borné).
- Arbre GPON : en-têtes OLT/slot/port en flex non-enroulé (nom·IP·adresse·nb
clients·SNMP·slots·éditer) → débordement. `flex-wrap:wrap` + neutralisation du
`margin-left:auto` des compteurs sur mobile.
- Boutons d'action de l'arbre (éditer OLT, ajouter port, éditer lieu) étaient
révélés au survol (opacity:0) → invisibles au tactile. Toujours visibles < 600px.
Vérifié à 375px : docScrollW 493→375, en-têtes enroulées, crayon éditer visible.
Portée strictement mobile (@media max-width:599px) — desktop inchangé.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1839 lines
79 KiB
Vue
1839 lines
79 KiB
Vue
<template>
|
|
<q-page padding>
|
|
<!-- Tabs -->
|
|
<div class="row items-center q-mb-md">
|
|
<q-tabs v-model="activeTab" dense no-caps class="text-grey-7" active-color="primary" indicator-color="primary">
|
|
<q-tab name="gpon" icon="lan" label="Réseau GPON" />
|
|
<q-tab name="map" icon="map" label="Carte réseau" />
|
|
<q-tab name="topology" icon="hub" label="Topologie" />
|
|
<q-tab name="analysis" icon="psychology" label="Analyse IA" />
|
|
</q-tabs>
|
|
<q-space />
|
|
<template v-if="activeTab === 'gpon'">
|
|
<q-btn unelevated dense no-caps icon="add" label="Ajouter OLT" color="primary" @click="showOltModal = true" />
|
|
<div class="text-caption text-grey-6 q-ml-md">{{ totalLocations }} locations fibre</div>
|
|
</template>
|
|
<template v-if="activeTab === 'map'">
|
|
<q-btn flat dense no-caps icon="refresh" label="Rafraîchir" color="grey-7" :loading="mapLoading" @click="loadNetworkMap" />
|
|
</template>
|
|
<template v-if="activeTab === 'topology'">
|
|
<q-btn flat dense no-caps icon="refresh" label="Rafraîchir" color="grey-7" :loading="topoLoading" @click="loadTopology" />
|
|
</template>
|
|
<template v-if="activeTab === 'analysis'">
|
|
<q-btn unelevated dense no-caps icon="play_arrow" label="Lancer analyse" color="primary" :loading="analysisLoading" @click="runAnalysis" />
|
|
</template>
|
|
</div>
|
|
|
|
<!-- ═══════════ TAB: GPON ═══════════ -->
|
|
<div v-show="activeTab === 'gpon'">
|
|
|
|
<!-- Pause polling (test phase) -->
|
|
<div class="poller-card q-mb-md">
|
|
<div class="row items-center no-wrap q-gutter-md">
|
|
<q-icon name="bolt" size="22px"
|
|
:color="pollerState.device.paused || pollerState.olt.paused ? 'negative' : 'positive'" />
|
|
<div class="col">
|
|
<div class="text-weight-medium">Pollers réseau</div>
|
|
<div class="text-caption text-grey-7">
|
|
Désactive temporairement les sondes GenieACS + SNMP pendant la phase de test.
|
|
<span v-if="pollerState.device.paused || pollerState.olt.paused" class="text-negative">
|
|
· Certains pollers sont en pause.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<q-btn flat dense size="sm" icon="refresh" :loading="pollerLoading" @click="fetchPollerState" />
|
|
</div>
|
|
|
|
<q-separator spaced />
|
|
|
|
<div class="row q-col-gutter-md">
|
|
<div class="col-12 col-sm-6">
|
|
<div class="row items-center no-wrap">
|
|
<div class="col">
|
|
<div class="text-body2 text-weight-medium row items-center q-gutter-xs">
|
|
<span>Poller ONT (GenieACS)</span>
|
|
<q-chip
|
|
dense square size="sm"
|
|
:color="pollerState.device.paused ? 'negative' : 'positive'"
|
|
text-color="white"
|
|
:icon="pollerState.device.paused ? 'pause' : 'check'"
|
|
:label="pollerState.device.paused ? 'En pause' : 'Actif'"
|
|
class="poller-chip"
|
|
/>
|
|
</div>
|
|
<div class="text-caption text-grey-6">
|
|
{{ pollerState.device.paused ? 'Sondes GenieACS arrêtées' : 'Sonde ~6000 appareils / 5 min' }}
|
|
<template v-if="pollerState.device.lastChange">
|
|
· dernier changement {{ formatPollerTs(pollerState.device.lastChange) }}
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<q-toggle
|
|
:model-value="!pollerState.device.paused"
|
|
color="positive"
|
|
:disable="pollerLoading"
|
|
@update:model-value="(v) => setPollerPaused('device', !v)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-12 col-sm-6">
|
|
<div class="row items-center no-wrap">
|
|
<div class="col">
|
|
<div class="text-body2 text-weight-medium row items-center q-gutter-xs">
|
|
<span>Poller OLT (SNMP)</span>
|
|
<q-chip
|
|
dense square size="sm"
|
|
:color="pollerState.olt.paused ? 'negative' : 'positive'"
|
|
text-color="white"
|
|
:icon="pollerState.olt.paused ? 'pause' : 'check'"
|
|
:label="pollerState.olt.paused ? 'En pause' : 'Actif'"
|
|
class="poller-chip"
|
|
/>
|
|
</div>
|
|
<div class="text-caption text-grey-6">
|
|
{{ pollerState.olt.paused ? 'Sondes SNMP arrêtées' : '4 OLT / 5 min' }}
|
|
<template v-if="pollerState.olt.lastChange">
|
|
· dernier changement {{ formatPollerTs(pollerState.olt.lastChange) }}
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<q-toggle
|
|
:model-value="!pollerState.olt.paused"
|
|
color="positive"
|
|
:disable="pollerLoading"
|
|
@update:model-value="(v) => setPollerPaused('olt', !v)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Search bar (GPON tab) -->
|
|
<div class="row items-center q-mb-md q-col-gutter-sm">
|
|
<div class="col-12 col-md-4">
|
|
<q-input
|
|
v-model="search" dense outlined placeholder="OLT IP, slot, port, client, serial..."
|
|
class="ops-search" @update:model-value="onSearchInput"
|
|
>
|
|
<template #prepend><q-icon name="search" /></template>
|
|
<template #append>
|
|
<q-spinner v-if="loading" size="16px" color="grey-5" />
|
|
<q-icon v-else-if="search" name="close" class="cursor-pointer" @click="clearSearch" />
|
|
</template>
|
|
</q-input>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- OLT Tree -->
|
|
<div v-if="loading && !olts.length" class="text-center q-pa-xl">
|
|
<q-spinner size="32px" color="primary" />
|
|
<div class="text-grey-6 q-mt-sm">Chargement du réseau...</div>
|
|
</div>
|
|
|
|
<div v-else class="net-tree">
|
|
<div v-for="olt in filteredOlts" :key="olt.ip" class="net-olt">
|
|
<!-- OLT Header -->
|
|
<div class="net-olt-header" @click="toggleOlt(olt.ip)">
|
|
<q-icon :name="expandedOlts[olt.ip] ? 'expand_more' : 'chevron_right'" size="20px" class="q-mr-xs" />
|
|
<span class="net-olt-icon">📡</span>
|
|
<span class="net-olt-name">{{ olt.name || olt.ip }}</span>
|
|
<span class="net-olt-ip">{{ olt.ip }}</span>
|
|
<span v-if="olt.address" class="net-olt-addr">{{ olt.address }}</span>
|
|
<span class="net-olt-count">{{ olt.locationCount }} clients</span>
|
|
<span v-if="snmpStats[olt.ip] && !snmpStats[olt.ip].lastError" class="net-snmp-badge net-snmp-ok">
|
|
<span class="net-snmp-dot net-snmp-dot--green"></span>{{ snmpStats[olt.ip].onuCount }} ONUs
|
|
</span>
|
|
<span v-else-if="snmpStats[olt.ip] && snmpStats[olt.ip].lastError" class="net-snmp-badge net-snmp-err">
|
|
<span class="net-snmp-dot net-snmp-dot--red"></span>SNMP inaccessible
|
|
</span>
|
|
<span v-else class="net-snmp-badge net-snmp-none">
|
|
<span class="net-snmp-dot net-snmp-dot--grey"></span>Non configuré
|
|
</span>
|
|
<span class="net-olt-slots">{{ olt.slotCount }} slots</span>
|
|
<q-btn flat dense round size="xs" icon="edit" class="net-olt-edit-btn" @click.stop="startEditOlt(olt)" />
|
|
</div>
|
|
|
|
<!-- Slots -->
|
|
<div v-if="expandedOlts[olt.ip]" class="net-slots">
|
|
<div v-for="slot in olt.slots" :key="slot.num" class="net-slot">
|
|
<div class="net-slot-header" @click="toggleSlot(olt.ip, slot.num)">
|
|
<q-icon :name="expandedSlots[olt.ip + '/' + slot.num] ? 'expand_more' : 'chevron_right'" size="18px" class="q-mr-xs" />
|
|
<span class="net-slot-label">Slot {{ slot.num }}</span>
|
|
<span class="net-slot-count">{{ slot.locationCount }} clients · {{ slot.portCount }} ports</span>
|
|
</div>
|
|
|
|
<!-- Ports -->
|
|
<div v-if="expandedSlots[olt.ip + '/' + slot.num]" class="net-ports">
|
|
<div v-for="port in slot.ports" :key="port.num" class="net-port">
|
|
<div class="net-port-header" @click="togglePort(olt.ip, slot.num, port.num)">
|
|
<q-icon :name="expandedPorts[olt.ip + '/' + slot.num + '/' + port.num] ? 'expand_more' : 'chevron_right'" size="16px" class="q-mr-xs" />
|
|
<span class="net-port-label">Port {{ port.num }}</span>
|
|
<span class="net-port-count">{{ port.locations.length }} ONT</span>
|
|
<q-btn flat dense round size="xs" icon="add" class="net-port-add-btn"
|
|
@click.stop="startAddLocation(olt, slot.num, port.num)" title="Ajouter une adresse" />
|
|
</div>
|
|
|
|
<!-- Locations (clients) -->
|
|
<div v-if="expandedPorts[olt.ip + '/' + slot.num + '/' + port.num]" class="net-locations">
|
|
<div
|
|
v-for="loc in port.locations" :key="loc.name"
|
|
class="net-location"
|
|
:class="{ 'net-location-editing': editingLoc === loc.name }"
|
|
>
|
|
<div class="net-loc-main">
|
|
<span class="net-loc-ont">ONT:{{ loc.ont_id }}</span>
|
|
<span class="net-loc-customer" @click="goToClient(loc.customer)">{{ loc.location_name || loc.customer }}</span>
|
|
<span class="net-loc-address">{{ loc.address_line }}</span>
|
|
<span v-if="loc.city" class="net-loc-city">{{ loc.city }}</span>
|
|
<span v-if="loc.ont_serial" class="net-loc-serial">{{ loc.ont_serial }}</span>
|
|
<q-btn flat dense round size="xs" icon="edit" class="net-loc-edit-btn" @click.stop="startEdit(loc)" />
|
|
</div>
|
|
|
|
<!-- Inline edit location -->
|
|
<div v-if="editingLoc === loc.name" class="net-loc-edit" @click.stop>
|
|
<div class="net-edit-grid">
|
|
<div class="net-edit-section">
|
|
<div class="net-edit-section-title">Adresse</div>
|
|
<div class="net-edit-row">
|
|
<label>Adresse</label>
|
|
<q-input v-model="editForm.address_line" dense outlined class="net-edit-field-lg" />
|
|
</div>
|
|
<div class="net-edit-row">
|
|
<label>Ville</label>
|
|
<q-input v-model="editForm.city" dense outlined class="net-edit-field" />
|
|
</div>
|
|
<div class="net-edit-row">
|
|
<label>Code postal</label>
|
|
<q-input v-model="editForm.postal_code" dense outlined class="net-edit-field-sm" />
|
|
</div>
|
|
</div>
|
|
<div class="net-edit-section">
|
|
<div class="net-edit-section-title">Connexion OLT</div>
|
|
<div class="net-edit-row">
|
|
<label>OLT</label>
|
|
<q-select v-model="editForm.olt_ip" :options="oltIpOptions" dense outlined emit-value map-options class="net-edit-field" />
|
|
</div>
|
|
<div class="net-edit-row">
|
|
<label>Slot</label>
|
|
<q-input v-model.number="editForm.slot" type="number" dense outlined class="net-edit-field-sm" />
|
|
</div>
|
|
<div class="net-edit-row">
|
|
<label>Port</label>
|
|
<q-input v-model.number="editForm.port" type="number" dense outlined class="net-edit-field-sm" />
|
|
</div>
|
|
<div class="net-edit-row">
|
|
<label>ONT ID</label>
|
|
<q-input v-model.number="editForm.ont_id" type="number" dense outlined class="net-edit-field-sm" />
|
|
</div>
|
|
<div class="net-edit-row">
|
|
<label>Serial</label>
|
|
<q-input v-model="editForm.ont_serial" dense outlined class="net-edit-field" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="net-edit-actions">
|
|
<q-btn flat dense no-caps label="Annuler" color="grey-7" @click="cancelEdit" />
|
|
<q-btn unelevated dense no-caps label="Sauvegarder" color="primary" :loading="saving" @click="saveEdit" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add location inline form -->
|
|
<div v-if="addingAt === olt.ip + '/' + slot.num + '/' + port.num" class="net-location net-location-editing">
|
|
<div class="net-loc-edit" @click.stop>
|
|
<div class="net-edit-section-title" style="width:100%">Nouvelle adresse sur {{ olt.name || olt.ip }} — Slot {{ slot.num }} / Port {{ port.num }}</div>
|
|
<div class="net-edit-grid">
|
|
<div class="net-edit-section">
|
|
<div class="net-edit-row">
|
|
<label>Client</label>
|
|
<q-input v-model="addForm.customerSearch" dense outlined placeholder="Rechercher client..."
|
|
class="net-edit-field-lg" @update:model-value="searchCustomers" />
|
|
</div>
|
|
<div v-if="customerResults.length" class="net-customer-results">
|
|
<div v-for="c in customerResults" :key="c.name" class="net-customer-result"
|
|
:class="{ active: addForm.customer === c.name }" @click="selectCustomer(c)">
|
|
{{ c.customer_name }} <span class="text-grey-5">{{ c.name }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="net-edit-row">
|
|
<label>Adresse</label>
|
|
<q-input v-model="addForm.address_line" dense outlined class="net-edit-field-lg" />
|
|
</div>
|
|
<div class="net-edit-row">
|
|
<label>Ville</label>
|
|
<q-input v-model="addForm.city" dense outlined class="net-edit-field" />
|
|
</div>
|
|
<div class="net-edit-row">
|
|
<label>Code postal</label>
|
|
<q-input v-model="addForm.postal_code" dense outlined class="net-edit-field-sm" />
|
|
</div>
|
|
</div>
|
|
<div class="net-edit-section">
|
|
<div class="net-edit-row">
|
|
<label>ONT ID</label>
|
|
<q-input v-model.number="addForm.ont_id" type="number" dense outlined class="net-edit-field-sm" />
|
|
</div>
|
|
<div class="net-edit-row">
|
|
<label>Serial</label>
|
|
<q-input v-model="addForm.ont_serial" dense outlined class="net-edit-field" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="net-edit-actions">
|
|
<q-btn flat dense no-caps label="Annuler" color="grey-7" @click="addingAt = null" />
|
|
<q-btn unelevated dense no-caps label="Ajouter" color="primary" :loading="saving"
|
|
:disable="!addForm.customer" @click="saveNewLocation" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="!filteredOlts.length && !loading" class="text-center q-pa-xl text-grey-5">
|
|
{{ search ? 'Aucun résultat pour cette recherche' : 'Aucune donnée OLT' }}
|
|
</div>
|
|
</div>
|
|
|
|
</div><!-- end GPON tab -->
|
|
|
|
<!-- ═══════════ TAB: CARTE RÉSEAU ═══════════ -->
|
|
<div v-show="activeTab === 'map'">
|
|
<div v-if="mapLoading && !mapData" class="text-center q-pa-xl">
|
|
<q-spinner size="32px" color="primary" />
|
|
<div class="text-grey-6 q-mt-sm">Chargement de la carte réseau...</div>
|
|
</div>
|
|
|
|
<div v-else-if="mapData" class="netmap-container">
|
|
<!-- Summary bar -->
|
|
<div class="netmap-summary row q-col-gutter-sm q-mb-sm">
|
|
<div class="col-auto"><q-chip dense color="green-1" text-color="green-9" icon="location_on">{{ mapData.summary.totalSites }} sites</q-chip></div>
|
|
<div class="col-auto"><q-chip dense color="green-1" text-color="green-9" icon="check_circle">{{ mapData.summary.sitesUp }} UP</q-chip></div>
|
|
<div class="col-auto" v-if="mapData.summary.sitesDown"><q-chip dense color="red-1" text-color="red-9" icon="error">{{ mapData.summary.sitesDown }} dégradé</q-chip></div>
|
|
<div class="col-auto"><q-chip dense color="amber-1" text-color="amber-9" icon="router">{{ mapData.summary.totalOlts }} OLTs</q-chip></div>
|
|
<div class="col-auto" v-if="mapData.summary.totalOnus"><q-chip dense color="blue-1" text-color="blue-9" icon="people">{{ mapData.summary.totalOnusOnline }}/{{ mapData.summary.totalOnus }} ONUs</q-chip></div>
|
|
<div class="col-auto"><q-chip dense color="grey-2" text-color="grey-8" icon="cable">{{ mapData.summary.totalLinks }} liens fibre</q-chip></div>
|
|
</div>
|
|
|
|
<!-- Cytoscape Canvas -->
|
|
<div ref="cyContainer" class="netmap-canvas"></div>
|
|
|
|
<!-- Selected site detail panel -->
|
|
<div v-if="mapSelectedSite" class="netmap-detail q-mt-sm">
|
|
<q-card flat bordered>
|
|
<q-card-section class="q-pa-sm">
|
|
<div class="row items-center q-mb-xs">
|
|
<div class="text-subtitle1 text-weight-bold">{{ mapSelectedSite.name }}</div>
|
|
<q-space />
|
|
<q-chip dense :color="mapSelectedSite.status === 'up' ? 'green' : mapSelectedSite.status === 'degraded' ? 'red' : 'grey'" text-color="white" :label="mapSelectedSite.status" />
|
|
<q-btn flat dense round icon="close" size="sm" @click="mapSelectedSite = null" />
|
|
</div>
|
|
<div class="text-caption text-grey-6">{{ mapSelectedSite.city }} — {{ mapSelectedSite.address }}</div>
|
|
|
|
<!-- Devices at this site -->
|
|
<div class="q-mt-sm">
|
|
<div v-for="dev in mapSelectedSite.devices" :key="dev.ip" class="netmap-device row items-center q-py-xs"
|
|
:class="{ 'netmap-device--down': dev.kuma?.status === 0 }">
|
|
<q-icon :name="dev.type === 'router' ? 'router' : dev.type === 'olt' ? 'device_hub' : 'dns'" size="16px" class="q-mr-xs" />
|
|
<span class="text-weight-medium" style="font-size:12px">{{ dev.hostname }}</span>
|
|
<span class="text-grey-5 q-mx-xs">|</span>
|
|
<code style="font-size:11px">{{ dev.ip }}</code>
|
|
<span class="text-grey-5 q-mx-xs">|</span>
|
|
<span style="font-size:11px" class="text-grey-6">{{ dev.make }} {{ dev.model || '' }}</span>
|
|
<q-space />
|
|
<q-badge v-if="dev.kuma" :color="dev.kuma.status === 1 ? 'green' : dev.kuma.status === 0 ? 'red' : 'orange'" :label="dev.kuma.status === 1 ? 'UP' : 'DOWN'" />
|
|
<q-badge v-else color="grey-4" text-color="grey-7" label="No Kuma" />
|
|
<q-badge v-if="dev.olt" color="blue-1" text-color="blue-9" :label="`${dev.olt.onlineCount}/${dev.olt.onuCount} ONUs`" class="q-ml-xs" />
|
|
<q-badge v-if="dev.status === 'planned'" color="amber-1" text-color="amber-9" label="Planifié" class="q-ml-xs" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Connected links -->
|
|
<div class="q-mt-sm" v-if="mapSelectedSiteLinks.length">
|
|
<div class="text-caption text-weight-bold text-grey-7 q-mb-xs">Liens fibre :</div>
|
|
<div v-for="link in mapSelectedSiteLinks" :key="link.id" class="row items-center q-py-xs" style="font-size:11px">
|
|
<q-icon name="cable" size="14px" class="q-mr-xs text-grey-5" />
|
|
<span>→ {{ link.from === mapSelectedSiteId ? link.to : link.from }}</span>
|
|
<span class="text-grey-5 q-mx-xs">|</span>
|
|
<span v-if="link.speed_gbps" class="text-weight-medium">{{ link.speed_gbps }}G</span>
|
|
<span v-if="link.distance_km" class="text-grey-6 q-ml-xs">{{ link.distance_km }}km</span>
|
|
<span v-if="link.note" class="text-grey-5 q-ml-xs">{{ link.note }}</span>
|
|
</div>
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="text-center q-pa-xl text-grey-5">
|
|
Cliquez sur Rafraîchir pour charger la carte réseau
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════════ TAB: TOPOLOGY ═══════════ -->
|
|
<div v-show="activeTab === 'topology'">
|
|
<div v-if="topoLoading && !topoData" class="text-center q-pa-xl">
|
|
<q-spinner size="32px" color="primary" />
|
|
<div class="text-grey-6 q-mt-sm">Découverte de la topologie réseau...</div>
|
|
</div>
|
|
|
|
<div v-else-if="topoData" class="topo-container">
|
|
<!-- Summary Cards -->
|
|
<div class="topo-summary row q-col-gutter-sm q-mb-md">
|
|
<div class="col-auto" v-for="(count, type) in topoData.topology.summary" :key="type">
|
|
<q-card flat bordered class="topo-summary-card" :class="'topo-type-' + type">
|
|
<q-card-section class="q-pa-sm text-center">
|
|
<div class="topo-summary-count">{{ count }}</div>
|
|
<div class="topo-summary-label">{{ topoTypeLabel(type) }}</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
</div>
|
|
<div class="col-auto">
|
|
<q-card flat bordered class="topo-summary-card">
|
|
<q-card-section class="q-pa-sm text-center">
|
|
<div class="topo-summary-count">{{ topoData.topology.links.length }}</div>
|
|
<div class="topo-summary-label">Liens</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Network Hierarchy Visualization -->
|
|
<div class="topo-hierarchy">
|
|
<!-- Level 0: Core Switches -->
|
|
<div class="topo-level" v-if="topoByLevel[0]?.length">
|
|
<div class="topo-level-label">CORE</div>
|
|
<div class="topo-level-devices">
|
|
<div v-for="dev in topoByLevel[0]" :key="dev.host" class="topo-device topo-device--core"
|
|
:class="devClass(dev)"
|
|
@click="selectDevice(dev)">
|
|
<div class="topo-kuma-dot" :class="kumaClass(dev)" :title="kumaTitle(dev)"></div>
|
|
<q-icon :name="dev.icon" size="20px" />
|
|
<div class="topo-device-host">{{ dev.host }}</div>
|
|
<div v-if="dev.kuma" class="topo-kuma-label">{{ dev.kuma.name }}</div>
|
|
<div class="topo-device-health">
|
|
<div class="topo-health-bar">
|
|
<div class="topo-health-fill" :style="{ width: (dev.ifSummary?.health || 0) + '%' }"
|
|
:class="healthClass(dev.ifSummary?.health)"></div>
|
|
</div>
|
|
<span class="topo-health-text">{{ dev.ifSummary?.health ?? '?' }}%</span>
|
|
</div>
|
|
<div class="topo-device-stats">
|
|
{{ dev.ifSummary?.up || 0 }}<span class="text-green">▲</span>
|
|
{{ dev.ifSummary?.down || 0 }}<span class="text-red">▼</span>
|
|
/ {{ dev.ifSummary?.total || 0 }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Level 1: Edge Routers + Public Infra -->
|
|
<div class="topo-level" v-if="topoByLevel[1]?.length">
|
|
<div class="topo-level-label">EDGE / TRANSIT</div>
|
|
<div class="topo-level-devices">
|
|
<div v-for="dev in topoByLevel[1]" :key="dev.host" class="topo-device topo-device--edge"
|
|
:class="devClass(dev)"
|
|
@click="selectDevice(dev)">
|
|
<div class="topo-kuma-dot" :class="kumaClass(dev)" :title="kumaTitle(dev)"></div>
|
|
<q-icon :name="dev.icon" size="18px" />
|
|
<div class="topo-device-host">{{ dev.host }}</div>
|
|
<div class="topo-device-type">{{ dev.label }}</div>
|
|
<div class="topo-device-health">
|
|
<div class="topo-health-bar">
|
|
<div class="topo-health-fill" :style="{ width: (dev.ifSummary?.health || 0) + '%' }"
|
|
:class="healthClass(dev.ifSummary?.health)"></div>
|
|
</div>
|
|
<span class="topo-health-text">{{ dev.ifSummary?.health ?? '?' }}%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Level 2: OLT Gateways -->
|
|
<div class="topo-level" v-if="topoByLevel[2]?.length">
|
|
<div class="topo-level-label">OLT GATEWAYS</div>
|
|
<div class="topo-level-devices">
|
|
<div v-for="dev in topoByLevel[2]" :key="dev.host" class="topo-device topo-device--olt"
|
|
:class="devClass(dev)"
|
|
@click="selectDevice(dev)">
|
|
<div class="topo-kuma-dot" :class="kumaClass(dev)" :title="kumaTitle(dev)"></div>
|
|
<q-icon :name="dev.icon" size="18px" />
|
|
<div class="topo-device-host">{{ dev.host }}</div>
|
|
<div class="topo-device-health">
|
|
<div class="topo-health-bar">
|
|
<div class="topo-health-fill" :style="{ width: (dev.ifSummary?.health || 0) + '%' }"
|
|
:class="healthClass(dev.ifSummary?.health)"></div>
|
|
</div>
|
|
<span class="topo-health-text">{{ dev.ifSummary?.health ?? '?' }}%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- OLT chains (from dependency map) -->
|
|
<div class="topo-level" v-if="oltChains.length">
|
|
<div class="topo-level-label">OLTs (GPON)</div>
|
|
<div class="topo-level-devices">
|
|
<div v-for="chain in oltChains" :key="chain.leaf" class="topo-device topo-device--olt-leaf"
|
|
:class="{ 'topo-device--danger': chain.path[chain.path.length-1]?.health === 0 }">
|
|
<q-icon name="settings_input_antenna" size="16px" />
|
|
<div class="topo-device-host">{{ chain.oltName || chain.leaf }}</div>
|
|
<div v-if="chain.onuCount" class="topo-device-onus">
|
|
{{ chain.onlineCount }}/{{ chain.onuCount }} ONUs
|
|
<q-icon :name="chain.onlineCount === chain.onuCount ? 'check_circle' : 'warning'" size="12px"
|
|
:color="chain.onlineCount === chain.onuCount ? 'green' : 'orange'" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Dependency Chains -->
|
|
<div class="topo-chains q-mt-lg" v-if="topoData.chains?.length">
|
|
<div class="text-subtitle2 q-mb-sm">Chaînes de dépendance</div>
|
|
<div v-for="(chain, i) in topoData.chains.slice(0, 20)" :key="i" class="topo-chain">
|
|
<template v-for="(node, ni) in chain.path" :key="ni">
|
|
<span class="topo-chain-node" :class="'topo-chain-' + node.type"
|
|
:title="`${node.host} — ${node.label} — Santé: ${node.health}%`">
|
|
<span class="topo-chain-health" :class="healthClass(node.health)">●</span>
|
|
{{ node.host }}
|
|
<span v-if="node.onuCount" class="topo-chain-onus">({{ node.onlineCount }}/{{ node.onuCount }})</span>
|
|
</span>
|
|
<span v-if="ni < chain.path.length - 1" class="topo-chain-arrow">→</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Selected device detail -->
|
|
<div v-if="selectedDevice && selectedDeviceData" class="topo-detail q-mt-lg">
|
|
<q-card flat bordered>
|
|
<q-card-section>
|
|
<div class="row items-center q-mb-sm">
|
|
<q-icon :name="selectedDeviceData.icon" size="24px" class="q-mr-sm" />
|
|
<div>
|
|
<div class="text-h6" style="line-height:1.2">{{ selectedDeviceData.host }}</div>
|
|
<div class="text-caption text-grey-6">{{ selectedDeviceData.label }} — {{ selectedDeviceData.type }}</div>
|
|
</div>
|
|
<q-space />
|
|
<q-btn flat dense round icon="close" @click="selectedDevice = null" />
|
|
</div>
|
|
<div class="row q-col-gutter-sm q-mb-sm">
|
|
<div class="col-auto">
|
|
<q-badge color="green" :label="`${selectedDeviceData.ifSummary?.up || 0} up`" />
|
|
</div>
|
|
<div class="col-auto">
|
|
<q-badge color="red" :label="`${selectedDeviceData.ifSummary?.down || 0} down`" />
|
|
</div>
|
|
<div class="col-auto">
|
|
<q-badge color="grey" :label="`${selectedDeviceData.ifSummary?.total || 0} total`" />
|
|
</div>
|
|
<div v-if="selectedDeviceData.traffic" class="col-auto">
|
|
<q-badge color="blue" :label="`↓${selectedDeviceData.traffic.totalInGb}GB ↑${selectedDeviceData.traffic.totalOutGb}GB`" />
|
|
</div>
|
|
</div>
|
|
<!-- Connected links -->
|
|
<div class="text-caption text-grey-6 q-mb-xs">Connexions:</div>
|
|
<div v-for="link in deviceLinks" :key="link.from + link.to" class="topo-detail-link">
|
|
<q-icon :name="link.status === 'up' ? 'link' : link.status === 'down' ? 'link_off' : 'help'" size="14px"
|
|
:color="link.status === 'up' ? 'green' : link.status === 'down' ? 'red' : 'grey'" />
|
|
{{ link.from === selectedDevice ? link.to : link.from }}
|
|
<span class="text-grey-5">({{ link.type }}, via: {{ link.via }})</span>
|
|
<span v-if="link.oltName" class="text-green-4">{{ link.oltName }} — {{ link.onlineCount }}/{{ link.onuCount }} ONUs</span>
|
|
</div>
|
|
<!-- Interface detail -->
|
|
<div v-if="deviceInterfaces.length" class="q-mt-sm">
|
|
<div class="text-caption text-grey-6 q-mb-xs">Interfaces ({{ deviceInterfaces.length }}):</div>
|
|
<q-virtual-scroll :items="deviceInterfaces" v-slot="{ item }" style="max-height:300px">
|
|
<div class="topo-detail-if" :class="{ 'text-red': item.status === 'down' && item.speed > 0 }">
|
|
<span class="topo-if-status" :class="item.status === 'up' ? 'bg-green' : item.status === 'down' ? 'bg-red' : 'bg-grey'"></span>
|
|
{{ item.descr || item.ifDescr || '—' }}
|
|
<span v-if="item.speed" class="text-grey-5 q-ml-xs">{{ formatSpeed(item.speed) }}</span>
|
|
<span v-if="item.inErrors > 0" class="text-orange q-ml-xs">err:{{ item.inErrors }}</span>
|
|
</div>
|
|
</q-virtual-scroll>
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="text-center q-pa-xl text-grey-5">
|
|
Cliquez sur Rafraîchir pour découvrir la topologie réseau
|
|
</div>
|
|
</div><!-- end topology tab -->
|
|
|
|
<!-- ═══════════ TAB: AI ANALYSIS ═══════════ -->
|
|
<div v-show="activeTab === 'analysis'">
|
|
<div v-if="analysisLoading" class="text-center q-pa-xl">
|
|
<q-spinner size="32px" color="primary" />
|
|
<div class="text-grey-6 q-mt-sm">Analyse IA en cours... (lecture des logs SNMP + statut OLTs)</div>
|
|
</div>
|
|
|
|
<div v-else-if="analysisResult" class="analysis-container">
|
|
<!-- Severity banner -->
|
|
<q-banner :class="'analysis-severity analysis-severity--' + (analysisResult.severity || 'unknown')" rounded>
|
|
<template #avatar>
|
|
<q-icon :name="severityIcon(analysisResult.severity)" size="28px" />
|
|
</template>
|
|
<div class="text-h6">{{ analysisResult.summary_fr || analysisResult.root_cause || 'Analyse terminée' }}</div>
|
|
<div class="text-caption">
|
|
Sévérité: {{ (analysisResult.severity || 'N/A').toUpperCase() }}
|
|
<span v-if="analysisResult.network_health_score != null"> — Score réseau: {{ analysisResult.network_health_score }}/100</span>
|
|
<span v-if="analysisResult.trending"> — Tendance: {{ analysisResult.trending }}</span>
|
|
<span v-if="analysisResult._logSummary" class="q-ml-sm text-grey-4">
|
|
({{ analysisResult._logSummary.totalTraps }} traps, {{ analysisResult._logSummary.oltCount }} OLTs, {{ analysisResult._logSummary.stuckOffline }} ONUs bloqués)
|
|
</span>
|
|
</div>
|
|
</q-banner>
|
|
|
|
<!-- OLT Health Grid -->
|
|
<div class="q-mt-md" v-if="analysisResult.olt_health?.length">
|
|
<div class="text-subtitle2 q-mb-sm">État des OLTs</div>
|
|
<div class="row q-col-gutter-sm">
|
|
<div v-for="olt in analysisResult.olt_health" :key="olt.name" class="col-auto">
|
|
<q-card flat bordered class="olt-health-card" :class="'olt-health--' + olt.status">
|
|
<q-card-section class="q-pa-sm">
|
|
<div class="row items-center q-gutter-xs">
|
|
<q-icon :name="olt.status === 'ok' ? 'check_circle' : olt.status === 'warning' ? 'warning' : olt.status === 'critical' ? 'error' : 'cloud_off'"
|
|
:color="olt.status === 'ok' ? 'green' : olt.status === 'warning' ? 'orange' : 'red'" size="18px" />
|
|
<div>
|
|
<div class="text-weight-bold" style="font-size:12px">{{ olt.name }}</div>
|
|
<div class="text-caption text-grey-6">{{ olt.online }}/{{ olt.total }} ONUs</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="olt.issues_fr && olt.issues_fr !== 'OK'" class="text-caption text-warning q-mt-xs">{{ olt.issues_fr }}</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Active Issues -->
|
|
<div class="q-mt-md" v-if="analysisResult.active_issues?.length">
|
|
<div class="text-subtitle2 q-mb-sm">Problèmes actifs</div>
|
|
<q-card v-for="(issue, i) in analysisResult.active_issues" :key="i" flat bordered class="q-mb-sm">
|
|
<q-card-section class="q-pa-sm">
|
|
<div class="row items-center q-gutter-sm q-mb-xs">
|
|
<q-badge :color="issue.urgency === 'immédiat' ? 'red' : issue.urgency === 'court-terme' ? 'orange' : issue.urgency === 'surveillance' ? 'blue' : 'grey'"
|
|
:label="issue.urgency || 'info'" />
|
|
<q-badge outline color="grey-7" :label="issue.type?.replace(/_/g, ' ')" />
|
|
<span class="text-weight-bold" style="font-size:13px">{{ issue.olt }}</span>
|
|
<span v-if="issue.affected_customers" class="text-caption text-grey-6">~{{ issue.affected_customers }} clients</span>
|
|
</div>
|
|
<div class="analysis-text" style="font-size:13px">{{ issue.description_fr }}</div>
|
|
<div v-if="issue.action_fr" class="text-caption text-primary q-mt-xs">
|
|
<q-icon name="play_arrow" size="14px" /> {{ issue.action_fr }}
|
|
</div>
|
|
<div v-if="issue.evidence?.length" class="q-mt-xs">
|
|
<span v-for="(ev, ei) in issue.evidence" :key="ei" class="analysis-evidence">{{ ev }}</span>
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
</div>
|
|
|
|
<!-- No issues -->
|
|
<q-banner v-if="!analysisResult.active_issues?.length && analysisResult.severity === 'normal'" class="q-mt-md bg-green-1 text-green-9" rounded>
|
|
<template #avatar><q-icon name="check_circle" color="green" /></template>
|
|
Aucun problème détecté. Le réseau fonctionne normalement.
|
|
</q-banner>
|
|
|
|
<!-- Next check recommendation -->
|
|
<div v-if="analysisResult.next_check_fr" class="text-caption text-grey-6 q-mt-md q-pa-sm" style="background:#f8fafc;border-radius:6px">
|
|
<q-icon name="schedule" size="14px" /> {{ analysisResult.next_check_fr }}
|
|
</div>
|
|
|
|
<!-- Raw JSON toggle -->
|
|
<q-expansion-item label="Données brutes (JSON)" dense class="q-mt-md text-grey-6">
|
|
<pre class="analysis-raw">{{ JSON.stringify(analysisResult, null, 2) }}</pre>
|
|
</q-expansion-item>
|
|
</div>
|
|
|
|
<div v-else class="text-center q-pa-xl text-grey-5">
|
|
Lancez une analyse IA pour obtenir un diagnostic réseau basé sur les logs.<br>
|
|
<span class="text-caption">L'IA analyse les SNMP traps des OLTs, le statut live des ONUs, et les incidents actifs.</span>
|
|
</div>
|
|
</div><!-- end analysis tab -->
|
|
|
|
<!-- Add / Edit OLT Modal -->
|
|
<q-dialog v-model="showOltModal" persistent>
|
|
<q-card style="min-width: 400px">
|
|
<q-card-section>
|
|
<div class="text-h6">{{ editingOltIp ? 'Modifier OLT' : 'Ajouter un OLT' }}</div>
|
|
</q-card-section>
|
|
<q-card-section class="q-gutter-sm">
|
|
<q-input v-model="oltForm.ip" dense outlined label="Adresse IP" :disable="!!editingOltIp" />
|
|
<q-input v-model="oltForm.name" dense outlined label="Nom / Localisation" />
|
|
<q-input v-model="oltForm.address" dense outlined label="Adresse civique" />
|
|
<div class="row q-col-gutter-sm">
|
|
<div class="col-6">
|
|
<q-input v-model="oltForm.latitude" dense outlined label="Latitude" type="number" step="any" />
|
|
</div>
|
|
<div class="col-6">
|
|
<q-input v-model="oltForm.longitude" dense outlined label="Longitude" type="number" step="any" />
|
|
</div>
|
|
</div>
|
|
<q-select v-model="oltForm.tech" dense outlined label="Technologie"
|
|
:options="[{ label: 'Huawei', value: 1 }, { label: 'Raisecom', value: 2 }, { label: 'TP-Link', value: 3 }]"
|
|
emit-value map-options @update:model-value="onTechChange" />
|
|
<q-input v-model="oltForm.snmpCommunity" dense outlined label="SNMP Community" />
|
|
<q-select v-model="oltForm.snmpType" dense outlined label="SNMP Type"
|
|
:options="[{ label: 'TP-Link', value: 'tplink' }, { label: 'Raisecom', value: 'raisecom' }]"
|
|
emit-value map-options />
|
|
</q-card-section>
|
|
<q-card-actions align="right">
|
|
<q-btn flat no-caps label="Annuler" color="grey-7" @click="closeOltModal" />
|
|
<q-btn unelevated no-caps :label="editingOltIp ? 'Sauvegarder' : 'Ajouter'" color="primary"
|
|
:loading="saving" :disable="!oltForm.ip || !oltForm.name" @click="saveOlt" />
|
|
</q-card-actions>
|
|
</q-card>
|
|
</q-dialog>
|
|
</q-page>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, watch, nextTick, reactive } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { listDocs, updateDoc, createDoc } from 'src/api/erp'
|
|
import { Notify } from 'quasar'
|
|
|
|
const router = useRouter()
|
|
import { HUB_URL as hubUrl } from 'src/config/hub'
|
|
const activeTab = ref('gpon')
|
|
const loading = ref(false)
|
|
const saving = ref(false)
|
|
const search = ref('')
|
|
const locations = ref([])
|
|
const oltRegistry = ref([]) // OLTs with metadata (address, coords)
|
|
const snmpStats = reactive({}) // keyed by OLT IP → { onuCount, onlineCount, lastPoll, lastError }
|
|
|
|
// Poller pause (test phase) ─ hub /admin/pollers state
|
|
const pollerLoading = ref(false)
|
|
const pollerState = reactive({
|
|
device: { paused: false, lastChange: null, reason: '' },
|
|
olt: { paused: false, lastChange: null, reason: '' },
|
|
})
|
|
async function fetchPollerState () {
|
|
pollerLoading.value = true
|
|
try {
|
|
const res = await fetch(`${hubUrl}/admin/pollers`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
if (data.device) Object.assign(pollerState.device, data.device)
|
|
if (data.olt) Object.assign(pollerState.olt, data.olt)
|
|
}
|
|
} catch (e) { /* silent — leave defaults */ }
|
|
finally { pollerLoading.value = false }
|
|
}
|
|
async function setPollerPaused (kind, paused) {
|
|
pollerLoading.value = true
|
|
try {
|
|
const res = await fetch(`${hubUrl}/admin/pollers`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ [kind]: { paused, reason: paused ? 'Test phase — ops SPA toggle' : '' } }),
|
|
})
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
if (data.state?.device) Object.assign(pollerState.device, data.state.device)
|
|
if (data.state?.olt) Object.assign(pollerState.olt, data.state.olt)
|
|
Notify.create({
|
|
type: paused ? 'warning' : 'positive',
|
|
message: `Poller ${kind === 'device' ? 'ONT' : 'OLT'} ${paused ? 'mis en pause' : 'réactivé'}`,
|
|
position: 'top', timeout: 1800,
|
|
})
|
|
} else throw new Error(`HTTP ${res.status}`)
|
|
} catch (e) {
|
|
Notify.create({ type: 'negative', message: `Échec: ${e.message}`, position: 'top' })
|
|
} finally { pollerLoading.value = false }
|
|
}
|
|
function formatPollerTs (iso) {
|
|
if (!iso) return ''
|
|
try { return new Date(iso).toLocaleString('fr-CA', { hour: '2-digit', minute: '2-digit', day: '2-digit', month: 'short' }) }
|
|
catch { return iso }
|
|
}
|
|
|
|
// Edit location state
|
|
const editingLoc = ref(null)
|
|
const editForm = reactive({ olt_ip: '', slot: 0, port: 0, ont_id: 0, ont_serial: '', address_line: '', city: '', postal_code: '' })
|
|
|
|
// Add location state
|
|
const addingAt = ref(null) // "ip/slot/port"
|
|
const addForm = reactive({ customer: '', customerSearch: '', customerName: '', address_line: '', city: '', postal_code: '', ont_id: 0, ont_serial: '' })
|
|
const customerResults = ref([])
|
|
let customerSearchTimer = null
|
|
|
|
// OLT modal state
|
|
const showOltModal = ref(false)
|
|
const editingOltIp = ref(null)
|
|
const oltForm = reactive({ ip: '', name: '', address: '', latitude: null, longitude: null, tech: 2, snmpCommunity: 'targosnmp', snmpType: 'raisecom' })
|
|
|
|
// Tree expand state
|
|
const expandedOlts = reactive({})
|
|
const expandedSlots = reactive({})
|
|
const expandedPorts = reactive({})
|
|
|
|
let searchTimer = null
|
|
|
|
// Build OLT tree from flat locations + registry
|
|
const olts = computed(() => {
|
|
const map = {}
|
|
|
|
// Seed from registry (so empty OLTs show up)
|
|
for (const reg of oltRegistry.value) {
|
|
map[reg.ip] = { ip: reg.ip, name: reg.name || '', address: reg.address || '', latitude: reg.latitude, longitude: reg.longitude, tech: reg.tech, slots: {} }
|
|
}
|
|
|
|
for (const loc of locations.value) {
|
|
if (!loc.olt_ip) continue
|
|
const parsed = parseOltPort(loc.olt_port)
|
|
const ip = loc.olt_ip
|
|
if (!map[ip]) map[ip] = { ip, name: loc.olt_name || '', address: '', slots: {} }
|
|
if (!map[ip].name && loc.olt_name) map[ip].name = loc.olt_name
|
|
const slotNum = parsed.slot
|
|
if (!map[ip].slots[slotNum]) map[ip].slots[slotNum] = { num: slotNum, ports: {} }
|
|
const portNum = parsed.port
|
|
if (!map[ip].slots[slotNum].ports[portNum]) map[ip].slots[slotNum].ports[portNum] = { num: portNum, locations: [] }
|
|
map[ip].slots[slotNum].ports[portNum].locations.push({
|
|
...loc,
|
|
ont_id: loc.ont_id ?? parsed.ontId,
|
|
})
|
|
}
|
|
|
|
return Object.values(map)
|
|
.map(olt => {
|
|
const slotArr = Object.values(olt.slots)
|
|
.map(s => {
|
|
const portArr = Object.values(s.ports)
|
|
.map(p => { p.locations.sort((a, b) => (a.ont_id || 0) - (b.ont_id || 0)); return p })
|
|
.sort((a, b) => a.num - b.num)
|
|
return { num: s.num, ports: portArr, portCount: portArr.length, locationCount: portArr.reduce((t, p) => t + p.locations.length, 0) }
|
|
})
|
|
.sort((a, b) => a.num - b.num)
|
|
return {
|
|
ip: olt.ip, name: olt.name, address: olt.address || '',
|
|
latitude: olt.latitude, longitude: olt.longitude, tech: olt.tech,
|
|
slots: slotArr,
|
|
slotCount: slotArr.length,
|
|
locationCount: slotArr.reduce((t, s) => t + s.locationCount, 0),
|
|
}
|
|
})
|
|
.sort((a, b) => a.ip.localeCompare(b.ip, undefined, { numeric: true }))
|
|
})
|
|
|
|
const totalLocations = computed(() => locations.value.filter(l => l.olt_ip).length)
|
|
const oltIpOptions = computed(() => olts.value.map(o => ({ label: `${o.name} (${o.ip})`, value: o.ip })))
|
|
|
|
// Filter tree by search
|
|
const filteredOlts = computed(() => {
|
|
const q = search.value.trim().toLowerCase()
|
|
if (!q) return olts.value
|
|
|
|
return olts.value
|
|
.map(olt => {
|
|
if (olt.ip.toLowerCase().includes(q) || olt.name.toLowerCase().includes(q) || (olt.address || '').toLowerCase().includes(q)) return olt
|
|
const filteredSlots = olt.slots
|
|
.map(slot => {
|
|
const filteredPorts = slot.ports
|
|
.map(port => {
|
|
const filteredLocs = port.locations.filter(loc =>
|
|
(loc.location_name || '').toLowerCase().includes(q) ||
|
|
(loc.customer || '').toLowerCase().includes(q) ||
|
|
(loc.address_line || '').toLowerCase().includes(q) ||
|
|
(loc.city || '').toLowerCase().includes(q) ||
|
|
(loc.ont_serial || '').toLowerCase().includes(q) ||
|
|
String(loc.ont_id).includes(q) ||
|
|
String(port.num).includes(q) ||
|
|
String(slot.num).includes(q)
|
|
)
|
|
if (!filteredLocs.length) return null
|
|
return { ...port, locations: filteredLocs }
|
|
})
|
|
.filter(Boolean)
|
|
if (!filteredPorts.length) return null
|
|
return { ...slot, ports: filteredPorts, portCount: filteredPorts.length, locationCount: filteredPorts.reduce((t, p) => t + p.locations.length, 0) }
|
|
})
|
|
.filter(Boolean)
|
|
if (!filteredSlots.length) return null
|
|
return { ...olt, slots: filteredSlots, slotCount: filteredSlots.length, locationCount: filteredSlots.reduce((t, s) => t + s.locationCount, 0) }
|
|
})
|
|
.filter(Boolean)
|
|
})
|
|
|
|
function parseOltPort (str) {
|
|
if (!str) return { frame: 0, slot: 0, port: 0, ontId: 0 }
|
|
const m = str.match(/(\d+)\/(\d+)\/(\d+)/)
|
|
const ontM = str.match(/ONT:(\d+)/)
|
|
return { frame: m ? parseInt(m[1]) : 0, slot: m ? parseInt(m[2]) : 0, port: m ? parseInt(m[3]) : 0, ontId: ontM ? parseInt(ontM[1]) : 0 }
|
|
}
|
|
|
|
function toggleOlt (ip) { expandedOlts[ip] = !expandedOlts[ip] }
|
|
function toggleSlot (ip, slot) { expandedSlots[ip + '/' + slot] = !expandedSlots[ip + '/' + slot] }
|
|
function togglePort (ip, slot, port) { expandedPorts[ip + '/' + slot + '/' + port] = !expandedPorts[ip + '/' + slot + '/' + port] }
|
|
|
|
function goToClient (customerId) {
|
|
if (customerId) router.push('/clients/' + customerId)
|
|
}
|
|
|
|
// ── OLT CRUD ──
|
|
|
|
function startEditOlt (olt) {
|
|
editingOltIp.value = olt.ip
|
|
oltForm.ip = olt.ip
|
|
oltForm.name = olt.name || ''
|
|
oltForm.address = olt.address || ''
|
|
oltForm.latitude = olt.latitude || null
|
|
oltForm.longitude = olt.longitude || null
|
|
oltForm.tech = olt.tech || 2
|
|
oltForm.snmpCommunity = olt.snmpCommunity || 'targosnmp'
|
|
oltForm.snmpType = olt.snmpType || (olt.tech === 2 ? 'raisecom' : 'tplink')
|
|
showOltModal.value = true
|
|
}
|
|
|
|
function onTechChange (val) {
|
|
oltForm.snmpType = val === 2 ? 'raisecom' : 'tplink'
|
|
}
|
|
|
|
function closeOltModal () {
|
|
showOltModal.value = false
|
|
editingOltIp.value = null
|
|
oltForm.ip = ''; oltForm.name = ''; oltForm.address = ''; oltForm.latitude = null; oltForm.longitude = null; oltForm.tech = 2
|
|
oltForm.snmpCommunity = 'targosnmp'; oltForm.snmpType = 'raisecom'
|
|
}
|
|
|
|
async function saveOlt () {
|
|
saving.value = true
|
|
try {
|
|
const data = {
|
|
olt_ip: oltForm.ip,
|
|
olt_name: oltForm.name,
|
|
olt_address: oltForm.address || '',
|
|
olt_latitude: oltForm.latitude || 0,
|
|
olt_longitude: oltForm.longitude || 0,
|
|
olt_tech: oltForm.tech || 2,
|
|
}
|
|
|
|
if (editingOltIp.value) {
|
|
// Update all Service Locations with this OLT IP
|
|
const locsToUpdate = locations.value.filter(l => l.olt_ip === editingOltIp.value)
|
|
for (const loc of locsToUpdate) {
|
|
await updateDoc('Service Location', loc.name, { olt_name: oltForm.name, network_notes: `OLT: ${oltForm.name}` })
|
|
loc.olt_name = oltForm.name
|
|
}
|
|
// Update registry
|
|
const reg = oltRegistry.value.find(r => r.ip === editingOltIp.value)
|
|
const snmpFields = { snmpCommunity: oltForm.snmpCommunity, snmpType: oltForm.snmpType }
|
|
if (reg) Object.assign(reg, { name: oltForm.name, address: oltForm.address, latitude: oltForm.latitude, longitude: oltForm.longitude, tech: oltForm.tech, ...snmpFields })
|
|
else oltRegistry.value.push({ ip: oltForm.ip, name: oltForm.name, address: oltForm.address, latitude: oltForm.latitude, longitude: oltForm.longitude, tech: oltForm.tech, ...snmpFields })
|
|
saveOltRegistry()
|
|
syncOltToHub(oltRegistry.value.find(r => r.ip === editingOltIp.value) || { ip: oltForm.ip, snmpCommunity: oltForm.snmpCommunity, snmpType: oltForm.snmpType, name: oltForm.name })
|
|
Notify.create({ type: 'positive', message: `OLT ${oltForm.name} mis à jour (${locsToUpdate.length} locations)` })
|
|
} else {
|
|
// New OLT — add to registry
|
|
if (olts.value.find(o => o.ip === oltForm.ip)) {
|
|
Notify.create({ type: 'warning', message: 'Un OLT avec cette IP existe déjà' })
|
|
saving.value = false
|
|
return
|
|
}
|
|
oltRegistry.value.push({ ip: oltForm.ip, name: oltForm.name, address: oltForm.address, latitude: oltForm.latitude, longitude: oltForm.longitude, tech: oltForm.tech, snmpCommunity: oltForm.snmpCommunity, snmpType: oltForm.snmpType })
|
|
saveOltRegistry()
|
|
Notify.create({ type: 'positive', message: `OLT ${oltForm.name} ajouté` })
|
|
}
|
|
closeOltModal()
|
|
} catch (e) {
|
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
// Persist OLT registry to localStorage (lightweight — will be backed by doctype later)
|
|
function saveOltRegistry () {
|
|
localStorage.setItem('olt-registry', JSON.stringify(oltRegistry.value))
|
|
}
|
|
|
|
function loadOltRegistry () {
|
|
try {
|
|
const stored = localStorage.getItem('olt-registry')
|
|
if (stored) oltRegistry.value = JSON.parse(stored)
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
// ── SNMP Stats ──
|
|
|
|
async function fetchSnmpStats () {
|
|
try {
|
|
const res = await fetch(`${hubUrl}/olt/stats`)
|
|
if (!res.ok) return
|
|
const data = await res.json()
|
|
for (const s of data) {
|
|
snmpStats[s.host] = { onuCount: s.onuCount || 0, onlineCount: s.onlineCount || 0, lastPoll: s.lastPoll, lastDuration: s.lastDuration, lastError: s.lastError || null }
|
|
}
|
|
} catch { /* hub unreachable — leave stats empty */ }
|
|
}
|
|
|
|
async function syncOltToHub (oltReg) {
|
|
try {
|
|
await fetch(`${hubUrl}/olt/config`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ host: oltReg.ip, name: oltReg.name, community: oltReg.snmpCommunity || 'targosnmp', type: oltReg.snmpType || 'tplink' }),
|
|
})
|
|
} catch { /* silent */ }
|
|
}
|
|
|
|
// ── Edit Location ──
|
|
|
|
function startEdit (loc) {
|
|
const parsed = parseOltPort(loc.olt_port)
|
|
editingLoc.value = loc.name
|
|
addingAt.value = null
|
|
editForm.olt_ip = loc.olt_ip || ''
|
|
editForm.slot = parsed.slot
|
|
editForm.port = parsed.port
|
|
editForm.ont_id = loc.ont_id ?? parsed.ontId
|
|
editForm.ont_serial = loc.ont_serial || ''
|
|
editForm.address_line = loc.address_line || ''
|
|
editForm.city = loc.city || ''
|
|
editForm.postal_code = loc.postal_code || ''
|
|
}
|
|
|
|
function cancelEdit () { editingLoc.value = null }
|
|
|
|
async function saveEdit () {
|
|
saving.value = true
|
|
try {
|
|
const frame = 0
|
|
const oltPort = `${editForm.olt_ip} ${frame}/${editForm.slot}/${editForm.port}` + (editForm.ont_id ? ` ONT:${editForm.ont_id}` : '')
|
|
const olt = olts.value.find(o => o.ip === editForm.olt_ip)
|
|
const oltName = olt ? olt.name : ''
|
|
|
|
await updateDoc('Service Location', editingLoc.value, {
|
|
olt_port: oltPort,
|
|
olt_ip: editForm.olt_ip,
|
|
olt_name: oltName,
|
|
ont_id: editForm.ont_id || 0,
|
|
ont_serial: editForm.ont_serial || '',
|
|
network_notes: oltName ? `OLT: ${oltName}` : '',
|
|
address_line: editForm.address_line,
|
|
city: editForm.city,
|
|
postal_code: editForm.postal_code,
|
|
})
|
|
|
|
const loc = locations.value.find(l => l.name === editingLoc.value)
|
|
if (loc) {
|
|
loc.olt_port = oltPort; loc.olt_ip = editForm.olt_ip; loc.olt_name = oltName
|
|
loc.ont_id = editForm.ont_id; loc.ont_serial = editForm.ont_serial
|
|
loc.address_line = editForm.address_line; loc.city = editForm.city; loc.postal_code = editForm.postal_code
|
|
}
|
|
|
|
Notify.create({ type: 'positive', message: 'Location mise à jour' })
|
|
editingLoc.value = null
|
|
} catch (e) {
|
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
// ── Add Location ──
|
|
|
|
function startAddLocation (olt, slotNum, portNum) {
|
|
editingLoc.value = null
|
|
addingAt.value = `${olt.ip}/${slotNum}/${portNum}`
|
|
addForm.customer = ''; addForm.customerSearch = ''; addForm.customerName = ''
|
|
addForm.address_line = ''; addForm.city = ''; addForm.postal_code = ''
|
|
addForm.ont_id = 0; addForm.ont_serial = ''
|
|
customerResults.value = []
|
|
// Auto-expand the port
|
|
expandedOlts[olt.ip] = true
|
|
expandedSlots[olt.ip + '/' + slotNum] = true
|
|
expandedPorts[olt.ip + '/' + slotNum + '/' + portNum] = true
|
|
}
|
|
|
|
function searchCustomers () {
|
|
clearTimeout(customerSearchTimer)
|
|
customerSearchTimer = setTimeout(async () => {
|
|
const q = addForm.customerSearch.trim()
|
|
if (q.length < 2) { customerResults.value = []; return }
|
|
try {
|
|
customerResults.value = await listDocs('Customer', {
|
|
or_filters: [['customer_name', 'like', `%${q}%`], ['name', 'like', `%${q}%`]],
|
|
fields: ['name', 'customer_name'],
|
|
limit: 8,
|
|
orderBy: 'customer_name asc',
|
|
})
|
|
} catch { customerResults.value = [] }
|
|
}, 300)
|
|
}
|
|
|
|
function selectCustomer (c) {
|
|
addForm.customer = c.name
|
|
addForm.customerName = c.customer_name
|
|
addForm.customerSearch = c.customer_name
|
|
customerResults.value = []
|
|
}
|
|
|
|
async function saveNewLocation () {
|
|
if (!addForm.customer) return
|
|
saving.value = true
|
|
try {
|
|
const [oltIp, slotStr, portStr] = addingAt.value.split('/')
|
|
const slot = parseInt(slotStr); const port = parseInt(portStr)
|
|
const frame = 0
|
|
const oltPort = `${oltIp} ${frame}/${slot}/${port}` + (addForm.ont_id ? ` ONT:${addForm.ont_id}` : '')
|
|
const olt = olts.value.find(o => o.ip === oltIp)
|
|
const oltName = olt ? olt.name : ''
|
|
|
|
const newLoc = await createDoc('Service Location', {
|
|
customer: addForm.customer,
|
|
location_name: addForm.customerName,
|
|
address_line: addForm.address_line,
|
|
city: addForm.city,
|
|
postal_code: addForm.postal_code,
|
|
connection_type: 'Fibre FTTH',
|
|
olt_port: oltPort,
|
|
olt_ip: oltIp,
|
|
olt_name: oltName,
|
|
ont_id: addForm.ont_id || 0,
|
|
ont_serial: addForm.ont_serial || '',
|
|
network_notes: oltName ? `OLT: ${oltName}` : '',
|
|
})
|
|
|
|
locations.value.push({
|
|
name: newLoc.name,
|
|
customer: addForm.customer,
|
|
location_name: addForm.customerName,
|
|
address_line: addForm.address_line,
|
|
city: addForm.city,
|
|
postal_code: addForm.postal_code,
|
|
olt_port: oltPort, olt_ip: oltIp, olt_name: oltName,
|
|
ont_id: addForm.ont_id, ont_serial: addForm.ont_serial,
|
|
})
|
|
|
|
Notify.create({ type: 'positive', message: `${addForm.customerName} ajouté sur port ${slot}/${port}` })
|
|
addingAt.value = null
|
|
} catch (e) {
|
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
// ── Search ──
|
|
|
|
function clearSearch () { search.value = '' }
|
|
|
|
function onSearchInput () {
|
|
clearTimeout(searchTimer)
|
|
searchTimer = setTimeout(() => {
|
|
if (search.value.trim()) {
|
|
for (const olt of filteredOlts.value) {
|
|
expandedOlts[olt.ip] = true
|
|
for (const slot of olt.slots) {
|
|
expandedSlots[olt.ip + '/' + slot.num] = true
|
|
for (const port of slot.ports) {
|
|
expandedPorts[olt.ip + '/' + slot.num + '/' + port.num] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}, 300)
|
|
}
|
|
|
|
// ── Load data ──
|
|
|
|
async function loadLocations () {
|
|
loading.value = true
|
|
try {
|
|
const data = await listDocs('Service Location', {
|
|
filters: { connection_type: 'Fibre FTTH' },
|
|
fields: ['name', 'customer', 'location_name', 'address_line', 'city', 'postal_code', 'olt_port', 'olt_ip', 'olt_name', 'ont_id', 'ont_serial'],
|
|
limit: 0,
|
|
orderBy: 'olt_ip asc, olt_port asc',
|
|
})
|
|
locations.value = data
|
|
} catch (e) {
|
|
Notify.create({ type: 'negative', message: 'Erreur chargement: ' + e.message })
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// ── Network Map tab (Cytoscape canvas) ──
|
|
|
|
const mapLoading = ref(false)
|
|
const mapData = ref(null)
|
|
const mapSelectedSite = ref(null)
|
|
const mapSelectedSiteId = ref(null)
|
|
const cyContainer = ref(null)
|
|
let cyInstance = null
|
|
let cytoscape = null // chargé à la demande (lib ~350 KB) — seulement quand on ouvre l'onglet Carte
|
|
|
|
const mapSelectedSiteLinks = computed(() => {
|
|
if (!mapData.value || !mapSelectedSiteId.value) return []
|
|
return mapData.value.links.filter(l => l.from === mapSelectedSiteId.value || l.to === mapSelectedSiteId.value)
|
|
})
|
|
|
|
async function loadNetworkMap () {
|
|
mapLoading.value = true
|
|
try {
|
|
const resp = await fetch(`${hubUrl}/network/map`)
|
|
mapData.value = await resp.json()
|
|
if (!cytoscape) cytoscape = (await import('cytoscape')).default // import dynamique : sort la lib du chunk NetworkPage
|
|
await nextTick()
|
|
renderCytoscape()
|
|
} catch (e) {
|
|
Notify.create({ type: 'negative', message: `Erreur carte: ${e.message}` })
|
|
} finally {
|
|
mapLoading.value = false
|
|
}
|
|
}
|
|
|
|
function renderCytoscape () {
|
|
if (!cyContainer.value || !mapData.value) return
|
|
if (cyInstance) { cyInstance.destroy(); cyInstance = null }
|
|
|
|
const elements = []
|
|
const data = mapData.value
|
|
|
|
// Add site nodes
|
|
for (const [id, site] of Object.entries(data.sites)) {
|
|
const isDatacenter = site.type === 'datacenter'
|
|
const olts = site.devices.filter(d => d.type === 'olt')
|
|
const routers = site.devices.filter(d => d.type === 'router')
|
|
const onuLabel = site.onuTotal > 0 ? `\n${site.onuOnline}/${site.onuTotal} ONUs` : ''
|
|
const oltLabel = olts.length > 0 ? `\n${olts.length} OLT${olts.length > 1 ? 's' : ''}` : ''
|
|
|
|
elements.push({
|
|
group: 'nodes',
|
|
data: {
|
|
id,
|
|
label: `${site.name}${oltLabel}${onuLabel}`,
|
|
status: site.status,
|
|
type: site.type,
|
|
onuTotal: site.onuTotal,
|
|
onuOnline: site.onuOnline,
|
|
oltCount: olts.length,
|
|
routerCount: routers.length,
|
|
isDatacenter,
|
|
},
|
|
// Use lat/lng for initial positioning (scaled to canvas)
|
|
position: site.lat && site.lng ? {
|
|
x: (site.lng + 74.5) * 2000,
|
|
y: (45.6 - site.lat) * 3000,
|
|
} : undefined,
|
|
})
|
|
}
|
|
|
|
// Add link edges
|
|
for (const link of data.links) {
|
|
const speedLabel = link.speed_gbps ? `${link.speed_gbps}G` : ''
|
|
const distLabel = link.distance_km ? `${link.distance_km}km` : ''
|
|
elements.push({
|
|
group: 'edges',
|
|
data: {
|
|
id: link.id || `${link.from}-${link.to}`,
|
|
source: link.from,
|
|
target: link.to,
|
|
label: [speedLabel, distLabel].filter(Boolean).join(' / '),
|
|
speed: link.speed_gbps || 0,
|
|
status: link.status,
|
|
linkType: link.type,
|
|
},
|
|
})
|
|
}
|
|
|
|
cyInstance = cytoscape({
|
|
container: cyContainer.value,
|
|
elements,
|
|
userZoomingEnabled: true,
|
|
userPanningEnabled: true,
|
|
boxSelectionEnabled: false,
|
|
style: [
|
|
{
|
|
selector: 'node',
|
|
style: {
|
|
'label': 'data(label)',
|
|
'text-valign': 'bottom',
|
|
'text-halign': 'center',
|
|
'font-size': '10px',
|
|
'font-family': 'Inter, system-ui, sans-serif',
|
|
'text-wrap': 'wrap',
|
|
'text-max-width': '100px',
|
|
'color': '#334155',
|
|
'width': 40,
|
|
'height': 40,
|
|
'shape': 'roundrectangle',
|
|
'background-color': '#e2e8f0',
|
|
'border-width': 2,
|
|
'border-color': '#94a3b8',
|
|
'text-margin-y': 6,
|
|
},
|
|
},
|
|
// Status colors
|
|
{
|
|
selector: 'node[status="up"]',
|
|
style: {
|
|
'background-color': '#dcfce7',
|
|
'border-color': '#22c55e',
|
|
},
|
|
},
|
|
{
|
|
selector: 'node[status="degraded"]',
|
|
style: {
|
|
'background-color': '#fef2f2',
|
|
'border-color': '#ef4444',
|
|
},
|
|
},
|
|
{
|
|
selector: 'node[status="unknown"]',
|
|
style: {
|
|
'background-color': '#f1f5f9',
|
|
'border-color': '#cbd5e1',
|
|
},
|
|
},
|
|
// Datacenter nodes bigger
|
|
{
|
|
selector: 'node[?isDatacenter]',
|
|
style: {
|
|
'width': 55,
|
|
'height': 55,
|
|
'font-size': '11px',
|
|
'font-weight': 'bold',
|
|
'shape': 'diamond',
|
|
'border-width': 3,
|
|
'background-color': '#e0e7ff',
|
|
'border-color': '#6366f1',
|
|
},
|
|
},
|
|
// OLT-heavy sites
|
|
{
|
|
selector: 'node[oltCount > 2]',
|
|
style: {
|
|
'width': 48,
|
|
'height': 48,
|
|
},
|
|
},
|
|
// Selected node
|
|
{
|
|
selector: 'node:selected',
|
|
style: {
|
|
'border-color': '#4f46e5',
|
|
'border-width': 3,
|
|
'overlay-color': '#4f46e5',
|
|
'overlay-padding': 4,
|
|
'overlay-opacity': 0.15,
|
|
},
|
|
},
|
|
// Edges
|
|
{
|
|
selector: 'edge',
|
|
style: {
|
|
'width': 2,
|
|
'line-color': '#94a3b8',
|
|
'curve-style': 'bezier',
|
|
'label': 'data(label)',
|
|
'font-size': '8px',
|
|
'font-family': 'monospace',
|
|
'color': '#64748b',
|
|
'text-rotation': 'autorotate',
|
|
'text-background-color': '#ffffff',
|
|
'text-background-opacity': 0.85,
|
|
'text-background-padding': '2px',
|
|
},
|
|
},
|
|
// Speed-based edge width
|
|
{ selector: 'edge[speed >= 100]', style: { 'width': 5, 'line-color': '#6366f1' } },
|
|
{ selector: 'edge[speed >= 25]', style: { 'width': 4, 'line-color': '#818cf8' } },
|
|
{ selector: 'edge[speed >= 10]', style: { 'width': 3, 'line-color': '#a5b4fc' } },
|
|
// DWDM links dashed
|
|
{ selector: 'edge[linkType="dwdm"]', style: { 'line-style': 'dashed', 'line-dash-pattern': [8, 4] } },
|
|
// Link status
|
|
{ selector: 'edge[status="up"]', style: { 'line-color': '#22c55e' } },
|
|
{ selector: 'edge[status="degraded"]', style: { 'line-color': '#ef4444', 'line-style': 'dashed' } },
|
|
],
|
|
layout: {
|
|
name: 'preset', // Use geo coordinates
|
|
fit: true,
|
|
padding: 40,
|
|
},
|
|
})
|
|
|
|
// Click handler
|
|
cyInstance.on('tap', 'node', (evt) => {
|
|
const nodeId = evt.target.id()
|
|
mapSelectedSiteId.value = nodeId
|
|
mapSelectedSite.value = data.sites[nodeId]
|
|
})
|
|
|
|
// Click on background clears selection
|
|
cyInstance.on('tap', (evt) => {
|
|
if (evt.target === cyInstance) {
|
|
mapSelectedSite.value = null
|
|
mapSelectedSiteId.value = null
|
|
}
|
|
})
|
|
}
|
|
|
|
// Auto-load map when tab switches
|
|
watch(activeTab, (tab) => {
|
|
if (tab === 'map' && !mapData.value) loadNetworkMap()
|
|
})
|
|
|
|
// ── Topology tab ──
|
|
|
|
const topoLoading = ref(false)
|
|
const topoData = ref(null)
|
|
const selectedDevice = ref(null)
|
|
const deviceInterfaces = ref([])
|
|
|
|
const topoByLevel = computed(() => {
|
|
if (!topoData.value) return {}
|
|
const levels = {}
|
|
for (const dev of topoData.value.topology.devices) {
|
|
const lvl = dev.level ?? 4
|
|
if (!levels[lvl]) levels[lvl] = []
|
|
levels[lvl].push(dev)
|
|
}
|
|
// Sort each level by host
|
|
for (const lvl in levels) levels[lvl].sort((a, b) => a.host.localeCompare(b.host, undefined, { numeric: true }))
|
|
return levels
|
|
})
|
|
|
|
const oltChains = computed(() => {
|
|
if (!topoData.value) return []
|
|
return topoData.value.chains.filter(c => c.leafType === 'olt')
|
|
})
|
|
|
|
const selectedDeviceData = computed(() => {
|
|
if (!selectedDevice.value || !topoData.value) return null
|
|
return topoData.value.topology.devices.find(d => d.host === selectedDevice.value)
|
|
})
|
|
|
|
const deviceLinks = computed(() => {
|
|
if (!selectedDevice.value || !topoData.value) return []
|
|
return topoData.value.topology.links.filter(l => l.from === selectedDevice.value || l.to === selectedDevice.value)
|
|
})
|
|
|
|
function topoTypeLabel (type) {
|
|
const labels = { oltGateways: 'OLT Gateways', edgeRouters: 'Edge Routers', coreSwitches: 'Core Switches', servers: 'Serveurs', publicInfra: 'Infra Publique', unknown: 'Inconnu' }
|
|
return labels[type] || type
|
|
}
|
|
|
|
function healthClass (health) {
|
|
if (health == null) return 'topo-h-unknown'
|
|
if (health >= 90) return 'topo-h-good'
|
|
if (health >= 70) return 'topo-h-warn'
|
|
return 'topo-h-crit'
|
|
}
|
|
|
|
function devClass (dev) {
|
|
const hasKuma = dev.kuma != null
|
|
const kumaUp = dev.kuma?.status === 1
|
|
const kumaDown = dev.kuma?.status === 0
|
|
const noData = !hasKuma && (dev.ifSummary?.activeTotal || 0) === 0 // no Kuma + no SNMP = unknown
|
|
return {
|
|
'topo-device--selected': selectedDevice.value === dev.host,
|
|
'topo-device--danger': kumaDown, // only red if Kuma confirms DOWN
|
|
'topo-device--ok': kumaUp, // green if Kuma confirms UP
|
|
'topo-device--nodata': noData, // grey if no monitoring data at all
|
|
'topo-device--warning': !hasKuma && !noData && dev.ifSummary?.health < 50, // some SNMP but unhealthy
|
|
}
|
|
}
|
|
|
|
function kumaClass (dev) {
|
|
if (!dev.kuma) return 'topo-kuma--unknown'
|
|
if (dev.kuma.status === 1) return 'topo-kuma--up'
|
|
if (dev.kuma.status === 0) return 'topo-kuma--down'
|
|
return 'topo-kuma--pending'
|
|
}
|
|
|
|
function kumaTitle (dev) {
|
|
if (!dev.kuma) return 'Pas monitoré dans Kuma'
|
|
const st = dev.kuma.status === 1 ? 'UP' : dev.kuma.status === 0 ? 'DOWN' : 'Pending'
|
|
const rt = dev.kuma.responseTime ? ` (${Math.round(dev.kuma.responseTime)}ms)` : ''
|
|
return `${dev.kuma.name}: ${st}${rt}`
|
|
}
|
|
|
|
function formatSpeed (speed) {
|
|
if (!speed) return ''
|
|
if (speed >= 1e9) return (speed / 1e9).toFixed(0) + 'G'
|
|
if (speed >= 1e6) return (speed / 1e6).toFixed(0) + 'M'
|
|
return speed + ''
|
|
}
|
|
|
|
function selectDevice (dev) {
|
|
selectedDevice.value = selectedDevice.value === dev.host ? null : dev.host
|
|
if (selectedDevice.value) loadDeviceInterfaces(dev.host)
|
|
}
|
|
|
|
async function loadDeviceInterfaces (host) {
|
|
try {
|
|
const res = await fetch(`${hubUrl}/network/health/${encodeURIComponent(host)}`)
|
|
if (!res.ok) return
|
|
const data = await res.json()
|
|
deviceInterfaces.value = data.interfaces || []
|
|
} catch { deviceInterfaces.value = [] }
|
|
}
|
|
|
|
async function loadTopology () {
|
|
topoLoading.value = true
|
|
try {
|
|
const res = await fetch(`${hubUrl}/network/dependency-map`)
|
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
topoData.value = await res.json()
|
|
} catch (e) {
|
|
Notify.create({ type: 'negative', message: 'Erreur topologie: ' + e.message })
|
|
} finally {
|
|
topoLoading.value = false
|
|
}
|
|
}
|
|
|
|
// ── AI Analysis tab ──
|
|
|
|
const analysisLoading = ref(false)
|
|
const analysisResult = ref(null)
|
|
|
|
function severityIcon (s) {
|
|
if (s === 'critical') return 'error'
|
|
if (s === 'high') return 'warning'
|
|
if (s === 'medium') return 'info'
|
|
return 'check_circle'
|
|
}
|
|
|
|
async function runAnalysis () {
|
|
analysisLoading.value = true
|
|
analysisResult.value = null
|
|
try {
|
|
const res = await fetch(`${hubUrl}/network/analyze`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ context: 'Analyse complète du réseau — identifie les pannes, risques et recommandations' }),
|
|
})
|
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
analysisResult.value = await res.json()
|
|
} catch (e) {
|
|
Notify.create({ type: 'negative', message: 'Erreur analyse: ' + e.message })
|
|
} finally {
|
|
analysisLoading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadOltRegistry()
|
|
loadLocations()
|
|
fetchSnmpStats()
|
|
fetchPollerState()
|
|
// Auto-load topology if on that tab
|
|
if (activeTab.value === 'topology') loadTopology()
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss">
|
|
.net-tree { max-width: 1100px; }
|
|
|
|
.poller-card {
|
|
padding: 14px 16px;
|
|
background: var(--ops-surface, #fff);
|
|
border: 1px solid var(--ops-border, #e2e8f0);
|
|
border-radius: 8px;
|
|
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
|
|
}
|
|
.poller-chip {
|
|
margin-left: 4px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
letter-spacing: .02em;
|
|
.q-icon { font-size: 14px; }
|
|
}
|
|
|
|
.net-olt { margin-bottom: 4px; }
|
|
.net-olt-header {
|
|
display: flex; align-items: center; gap: 6px;
|
|
padding: 10px 12px; cursor: pointer;
|
|
background: var(--ops-surface); border: 1px solid var(--ops-border);
|
|
border-radius: 6px;
|
|
font-weight: 600; font-size: 14px;
|
|
&:hover { background: var(--ops-bg-hover); }
|
|
&:hover .net-olt-edit-btn { opacity: 1; }
|
|
}
|
|
.net-olt-icon { font-size: 16px; }
|
|
.net-olt-name { color: var(--ops-text); }
|
|
.net-olt-ip { color: var(--ops-text-muted); font-family: monospace; font-size: 12px; font-weight: 400; }
|
|
.net-olt-addr { color: #94a3b8; font-size: 12px; font-weight: 400; font-style: italic; }
|
|
.net-olt-count { margin-left: auto; color: var(--ops-text-muted); font-size: 12px; font-weight: 400; }
|
|
.net-olt-slots { color: var(--ops-text-muted); font-size: 12px; font-weight: 400; }
|
|
.net-olt-edit-btn { opacity: 0; transition: opacity .15s; }
|
|
|
|
/* Mobile — arbre GPON : les en-têtes (OLT/slot/port) empilent trop d'infos sur une ligne (nom·IP·adresse·nb clients·SNMP·slots·éditer) → on enroule.
|
|
Les boutons d'action révélés au survol (opacity:0) sont inaccessibles au doigt → toujours visibles sur tactile. */
|
|
@media (max-width: 599px) {
|
|
.net-olt-header, .net-slot-header, .net-port-header { flex-wrap: wrap; }
|
|
.net-olt-count, .net-slot-count, .net-port-count { margin-left: 0; }
|
|
.net-olt-edit-btn, .net-port-add-btn, .net-loc-edit-btn { opacity: 1; }
|
|
}
|
|
|
|
.net-snmp-badge {
|
|
display: inline-flex; align-items: center; gap: 4px;
|
|
font-size: 11px; font-weight: 400; padding: 1px 8px;
|
|
border-radius: 10px; white-space: nowrap;
|
|
}
|
|
.net-snmp-ok { color: #15803d; background: #dcfce7; }
|
|
.net-snmp-err { color: #b91c1c; background: #fee2e2; }
|
|
.net-snmp-none { color: #6b7280; background: #f3f4f6; }
|
|
.net-snmp-dot {
|
|
display: inline-block; width: 7px; height: 7px; border-radius: 50%;
|
|
}
|
|
.net-snmp-dot--green { background: #22c55e; }
|
|
.net-snmp-dot--red { background: #ef4444; }
|
|
.net-snmp-dot--grey { background: #9ca3af; }
|
|
|
|
.net-slots { padding-left: 20px; }
|
|
.net-slot { margin-top: 2px; }
|
|
.net-slot-header {
|
|
display: flex; align-items: center; gap: 4px;
|
|
padding: 7px 10px; cursor: pointer;
|
|
background: #f1f5f9; border-radius: 4px;
|
|
font-size: 13px; font-weight: 500; color: var(--ops-text);
|
|
&:hover { background: var(--ops-bg-hover); }
|
|
}
|
|
.net-slot-label { font-family: monospace; }
|
|
.net-slot-count { margin-left: auto; font-size: 11px; color: var(--ops-text-muted); font-weight: 400; }
|
|
|
|
.net-ports { padding-left: 20px; }
|
|
.net-port { margin-top: 2px; }
|
|
.net-port-header {
|
|
display: flex; align-items: center; gap: 4px;
|
|
padding: 5px 8px; cursor: pointer;
|
|
background: #f8fafc; border-radius: 3px;
|
|
font-size: 12px; color: var(--ops-text-muted);
|
|
&:hover { background: var(--ops-bg-hover); }
|
|
&:hover .net-port-add-btn { opacity: 1; }
|
|
}
|
|
.net-port-label { font-family: monospace; font-weight: 500; color: var(--ops-text); }
|
|
.net-port-count { margin-left: auto; font-size: 11px; color: #94a3b8; }
|
|
.net-port-add-btn { opacity: 0; transition: opacity .15s; margin-left: 4px; }
|
|
|
|
.net-locations { padding-left: 24px; }
|
|
.net-location {
|
|
border-left: 2px solid var(--ops-border);
|
|
padding: 4px 0 4px 10px; margin: 1px 0;
|
|
&:hover { border-left-color: var(--ops-accent); }
|
|
&:hover .net-loc-edit-btn { opacity: 1; }
|
|
}
|
|
.net-location-editing { border-left-color: var(--ops-accent); background: #eef2ff; border-radius: 4px; padding: 8px 10px; }
|
|
|
|
.net-loc-main {
|
|
display: flex; align-items: center; gap: 8px;
|
|
font-size: 12px; flex-wrap: wrap;
|
|
}
|
|
.net-loc-ont {
|
|
font-family: monospace; font-size: 11px; font-weight: 600;
|
|
color: var(--ops-accent); background: #eef2ff; padding: 1px 6px; border-radius: 3px;
|
|
min-width: 52px; text-align: center;
|
|
}
|
|
.net-loc-customer {
|
|
font-weight: 500; color: var(--ops-text); cursor: pointer;
|
|
&:hover { color: var(--ops-accent); text-decoration: underline; }
|
|
}
|
|
.net-loc-address { color: var(--ops-text-muted); }
|
|
.net-loc-city { color: #94a3b8; }
|
|
.net-loc-serial { font-family: monospace; font-size: 11px; color: #94a3b8; }
|
|
.net-loc-edit-btn { opacity: 0; transition: opacity .15s; }
|
|
|
|
.net-loc-edit {
|
|
display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px;
|
|
padding: 12px; background: var(--ops-surface); border: 1px solid var(--ops-border);
|
|
border-radius: 6px;
|
|
}
|
|
.net-edit-grid {
|
|
display: flex; gap: 24px; width: 100%; flex-wrap: wrap;
|
|
}
|
|
.net-edit-section {
|
|
display: flex; flex-direction: column; gap: 6px; min-width: 240px;
|
|
}
|
|
.net-edit-section-title {
|
|
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
|
|
color: var(--ops-text-muted); margin-bottom: 2px;
|
|
}
|
|
.net-edit-row {
|
|
display: flex; align-items: center; gap: 6px;
|
|
label { font-size: 11px; color: var(--ops-text-muted); min-width: 70px; }
|
|
}
|
|
.net-edit-field { width: 180px; }
|
|
.net-edit-field-lg { width: 260px; }
|
|
.net-edit-field-sm { width: 80px; }
|
|
.net-edit-actions {
|
|
display: flex; gap: 6px; margin-left: auto; align-items: center; width: 100%; justify-content: flex-end;
|
|
margin-top: 4px; padding-top: 8px; border-top: 1px solid var(--ops-border);
|
|
}
|
|
|
|
.net-customer-results {
|
|
margin-left: 76px; background: var(--ops-surface); border: 1px solid var(--ops-border);
|
|
border-radius: 4px; max-height: 160px; overflow-y: auto; margin-bottom: 4px;
|
|
}
|
|
.net-customer-result {
|
|
padding: 6px 10px; font-size: 12px; cursor: pointer;
|
|
&:hover, &.active { background: var(--ops-bg-hover); }
|
|
}
|
|
|
|
/* ── Topology tab ── */
|
|
.topo-container { max-width: 1400px; }
|
|
|
|
.topo-summary-card {
|
|
min-width: 100px; border-radius: 8px;
|
|
.topo-summary-count { font-size: 24px; font-weight: 700; color: var(--ops-text); line-height: 1.2; }
|
|
.topo-summary-label { font-size: 11px; color: var(--ops-text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
|
}
|
|
|
|
.topo-hierarchy { display: flex; flex-direction: column; gap: 16px; }
|
|
|
|
.topo-level {
|
|
display: flex; align-items: flex-start; gap: 12px;
|
|
}
|
|
.topo-level-label {
|
|
min-width: 110px; font-size: 10px; font-weight: 700; letter-spacing: 1px;
|
|
color: var(--ops-text-muted); text-transform: uppercase; padding-top: 10px;
|
|
border-right: 2px solid var(--ops-border); padding-right: 10px; text-align: right;
|
|
}
|
|
.topo-level-devices {
|
|
display: flex; flex-wrap: wrap; gap: 8px; flex: 1;
|
|
}
|
|
|
|
.topo-device {
|
|
display: flex; flex-direction: column; align-items: center; gap: 2px;
|
|
padding: 8px 12px; border-radius: 8px; cursor: pointer;
|
|
border: 1px solid var(--ops-border); background: var(--ops-surface);
|
|
min-width: 110px; transition: all .15s; position: relative;
|
|
&:hover { border-color: #818cf8; box-shadow: 0 2px 8px rgba(99,102,241,.15); }
|
|
}
|
|
.topo-device--core { border-left: 3px solid #6366f1; }
|
|
.topo-device--edge { border-left: 3px solid #10b981; }
|
|
.topo-device--olt { border-left: 3px solid #f59e0b; }
|
|
.topo-device--olt-leaf { border-left: 3px solid #ef4444; min-width: 100px; padding: 6px 10px; }
|
|
.topo-device--selected { border-color: #6366f1 !important; background: #eef2ff; box-shadow: 0 2px 12px rgba(99,102,241,.2); }
|
|
.topo-device--ok { border-color: #22c55e; background: #f0fdf4; }
|
|
.topo-device--warning { border-color: #f59e0b; background: #fffbeb; }
|
|
.topo-device--danger { border-color: #ef4444; background: #fef2f2; }
|
|
.topo-device--nodata { border-color: #d1d5db; background: #f9fafb; opacity: .7; }
|
|
|
|
.topo-device-host { font-family: monospace; font-size: 11px; font-weight: 600; color: var(--ops-text); }
|
|
.topo-device-type { font-size: 10px; color: var(--ops-text-muted); }
|
|
.topo-device-stats { font-size: 10px; font-family: monospace; color: var(--ops-text-muted); }
|
|
.topo-kuma-label { font-size: 9px; color: var(--ops-text-muted); max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
|
/* Kuma reachability dot (top-right corner of card) */
|
|
.topo-kuma-dot {
|
|
position: absolute; top: 4px; right: 4px;
|
|
width: 8px; height: 8px; border-radius: 50%;
|
|
}
|
|
.topo-kuma--up { background: #22c55e; box-shadow: 0 0 4px #22c55e88; }
|
|
.topo-kuma--down { background: #ef4444; box-shadow: 0 0 4px #ef444488; animation: kuma-pulse 1.5s infinite; }
|
|
.topo-kuma--pending { background: #f59e0b; }
|
|
.topo-kuma--unknown { background: #9ca3af; }
|
|
@keyframes kuma-pulse { 0%,100% { opacity: 1; } 50% { opacity: .4; } }
|
|
.topo-device-onus { font-size: 10px; color: var(--ops-text-muted); display: flex; align-items: center; gap: 3px; }
|
|
|
|
.topo-device-health { display: flex; align-items: center; gap: 4px; width: 100%; }
|
|
.topo-health-bar { flex: 1; height: 4px; background: #e5e7eb; border-radius: 2px; overflow: hidden; }
|
|
.topo-health-fill { height: 100%; border-radius: 2px; transition: width .3s; }
|
|
.topo-health-text { font-size: 9px; font-weight: 600; min-width: 28px; text-align: right; }
|
|
|
|
.topo-h-good { background: #22c55e; color: #22c55e; }
|
|
.topo-h-warn { background: #f59e0b; color: #f59e0b; }
|
|
.topo-h-crit { background: #ef4444; color: #ef4444; }
|
|
.topo-h-unknown { background: #9ca3af; color: #9ca3af; }
|
|
|
|
/* Dependency chains */
|
|
.topo-chains { max-width: 1200px; }
|
|
.topo-chain {
|
|
display: flex; align-items: center; flex-wrap: wrap; gap: 4px;
|
|
padding: 4px 0; border-bottom: 1px solid #f1f5f9;
|
|
font-size: 12px;
|
|
}
|
|
.topo-chain-node {
|
|
display: inline-flex; align-items: center; gap: 3px;
|
|
padding: 2px 8px; border-radius: 4px; background: #f8fafc;
|
|
font-family: monospace; font-size: 11px;
|
|
}
|
|
.topo-chain-core-switch { border-left: 3px solid #6366f1; }
|
|
.topo-chain-edge-router { border-left: 3px solid #10b981; }
|
|
.topo-chain-olt-gateway { border-left: 3px solid #f59e0b; }
|
|
.topo-chain-olt { border-left: 3px solid #8b5cf6; }
|
|
.topo-chain-health { font-size: 10px; }
|
|
.topo-chain-onus { font-size: 10px; color: #64748b; }
|
|
.topo-chain-arrow { color: #94a3b8; font-weight: 600; }
|
|
|
|
/* Device detail panel */
|
|
.topo-detail { max-width: 700px; }
|
|
.topo-detail-link {
|
|
display: flex; align-items: center; gap: 6px;
|
|
padding: 3px 0; font-size: 12px;
|
|
}
|
|
.topo-detail-if {
|
|
display: flex; align-items: center; gap: 6px;
|
|
padding: 2px 0; font-size: 11px; font-family: monospace;
|
|
}
|
|
.topo-if-status {
|
|
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
|
}
|
|
|
|
/* ── Analysis tab ── */
|
|
.analysis-container { max-width: 900px; }
|
|
|
|
.analysis-severity {
|
|
color: white;
|
|
&--critical { background: #dc2626; }
|
|
&--high { background: #ea580c; }
|
|
&--medium { background: #d97706; }
|
|
&--low { background: #16a34a; }
|
|
&--unknown { background: #6b7280; }
|
|
}
|
|
|
|
.analysis-text { font-size: 14px; line-height: 1.6; color: var(--ops-text); white-space: pre-wrap; }
|
|
|
|
.analysis-chain {
|
|
font-family: monospace; font-size: 12px; padding: 4px 8px;
|
|
background: #f8fafc; border-radius: 4px; margin-bottom: 4px;
|
|
}
|
|
|
|
.analysis-action {
|
|
padding: 6px 0; border-bottom: 1px solid #f1f5f9; font-size: 13px;
|
|
}
|
|
|
|
.analysis-device {
|
|
padding: 4px 0; font-size: 13px; border-bottom: 1px solid #f8fafc;
|
|
code { background: #f1f5f9; padding: 1px 6px; border-radius: 3px; font-size: 12px; }
|
|
}
|
|
|
|
.analysis-evidence {
|
|
display: inline-block; font-size: 11px; color: #64748b; background: #f1f5f9;
|
|
padding: 1px 8px; border-radius: 10px; margin: 2px 4px 2px 0;
|
|
}
|
|
|
|
.olt-health-card { min-width: 140px; border-radius: 8px; }
|
|
.olt-health--ok { border-left: 3px solid #22c55e; }
|
|
.olt-health--warning { border-left: 3px solid #f59e0b; }
|
|
.olt-health--critical { border-left: 3px solid #ef4444; }
|
|
.olt-health--unreachable { border-left: 3px solid #6b7280; background: #f9fafb; }
|
|
|
|
.analysis-raw {
|
|
font-size: 11px; background: #1e293b; color: #e2e8f0; padding: 12px;
|
|
border-radius: 6px; overflow-x: auto; max-height: 400px;
|
|
}
|
|
|
|
/* ── Network Map (Cytoscape) ── */
|
|
.netmap-container { display: flex; flex-direction: column; height: calc(100vh - 180px); }
|
|
.netmap-canvas {
|
|
flex: 1; min-height: 400px; border: 1px solid var(--ops-border, #e2e8f0);
|
|
border-radius: 8px; background: #fafbfc;
|
|
}
|
|
.netmap-detail { max-height: 300px; overflow-y: auto; }
|
|
.netmap-device { border-bottom: 1px solid #f1f5f9; }
|
|
.netmap-device--down { background: #fef2f2; }
|
|
</style>
|