gigafibre-fsm/apps/ops/src/pages/NetworkPage.vue
louispaulb 69fc971c04 feat: contract → chain → subscription → prorated invoice lifecycle + tech group claim
- contracts.js: built-in install chain fallback when no Flow Template matches
  on_contract_signed — every accepted contract now creates a master Issue +
  chained Dispatch Jobs (fiber_install template) so we never lose a signed
  contract to a missing flow config.
- acceptance.js: export createDeferredJobs + propagate assigned_group into
  Dispatch Job payload (was only in notes, not queryable).
- dispatch.js: chain-walk helpers (unblockDependents, _isChainTerminal,
  setJobStatusWithChain) + terminal-node detection that activates pending
  Service Subscriptions (En attente → Actif, start_date=tomorrow) and emits
  a prorated Sales Invoice covering tomorrow → EOM. Courtesy-day billing
  convention: activation day is free, first period starts next day.
- dispatch.js: fix Sales Invoice 417 by resolving company default income
  account (Ventes - T) and passing company + income_account on each item.
- dispatch.js: GET /dispatch/group-jobs + POST /dispatch/claim-job for tech
  self-assignment from the group queue; enriches with customer_name /
  service_location via per-job fetches since those fetch_from fields aren't
  queryable in list API.
- TechTasksPage.vue: redesigned mobile-first UI with progress arc, status
  chips, and new "Tâches du groupe" section showing claimable unassigned
  jobs with a "Prendre" CTA. Live updates via SSE job-claimed / job-unblocked.
- NetworkPage.vue + poller-control.js: poller toggle semantics flipped —
  green when enabled, red/gray when paused; explicit status chips for clarity.

E2E verified end-to-end: CTR-00007 → 4 chained jobs → claim → In Progress →
Completed walks chain → SUB-0000100002 activated (start=2026-04-24) →
SINV-2026-700012 prorata $9.32 (= 39.95 × 7/30).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 20:40:54 -04:00

1830 lines
78 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="indigo-6" indicator-color="indigo-6">
<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="indigo-6" @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="indigo-6" :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="indigo-6" />
<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">&#x1F4E1;</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&eacute;
</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 &middot; {{ 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="indigo-6" :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 }} &mdash; 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="indigo-6" :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="indigo-6" />
<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="indigo-1" text-color="indigo-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-2" 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-2" 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="indigo-6" />
<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-indigo-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="indigo-6" />
<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-orange-8 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-indigo-6 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="indigo-6"
: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 cytoscape from 'cytoscape'
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
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()
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; }
.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>