mirror of
https://github.com/opnsense/plugins.git
synced 2026-05-28 04:34:15 -04:00
Add topology map plugin: initial implementation with discovery features and UI integration
This commit is contained in:
parent
15a8c47f72
commit
8a546533f0
14 changed files with 684 additions and 0 deletions
7
net-mgmt/topology-map/Makefile
Normal file
7
net-mgmt/topology-map/Makefile
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
PLUGIN_NAME= topology-map
|
||||
PLUGIN_VERSION= 1.0_1
|
||||
PLUGIN_COMMENT= Network topology mapper with LLDP/ARP/NDP discovery
|
||||
PLUGIN_MAINTAINER= kuiper@local
|
||||
PLUGIN_WWW= https://opnsense.org
|
||||
|
||||
.include "../../Mk/plugins.mk"
|
||||
6
net-mgmt/topology-map/pkg-descr
Normal file
6
net-mgmt/topology-map/pkg-descr
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
Network topology mapper for OPNsense.
|
||||
|
||||
This plugin discovers local network nodes and links using LLDP, ARP and NDP,
|
||||
then exposes a topology view and API endpoints suitable for dashboard summaries.
|
||||
|
||||
WWW: <https://opnsense.org>
|
||||
|
|
@ -0,0 +1,308 @@
|
|||
<?php
|
||||
|
||||
namespace OPNsense\TopologyMap\Api;
|
||||
|
||||
use OPNsense\Base\ApiControllerBase;
|
||||
use OPNsense\Core\Backend;
|
||||
use OPNsense\TopologyMap\TopologyMap;
|
||||
|
||||
class ServiceController extends ApiControllerBase
|
||||
{
|
||||
private function isPublicIp($ip)
|
||||
{
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return filter_var(
|
||||
$ip,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||
) !== false;
|
||||
}
|
||||
|
||||
private function getSettings()
|
||||
{
|
||||
$model = new TopologyMap();
|
||||
return $model->general;
|
||||
}
|
||||
|
||||
private function parseArp($output)
|
||||
{
|
||||
$items = [];
|
||||
foreach (preg_split('/\r?\n/', (string)$output) as $line) {
|
||||
if (preg_match('/\(([^\)]+)\)\s+at\s+([0-9a-f:]+)\s+on\s+(\S+)/i', $line, $m)) {
|
||||
$items[] = [
|
||||
'ip' => $m[1],
|
||||
'mac' => strtolower($m[2]),
|
||||
'if' => $m[3],
|
||||
'source' => 'arp'
|
||||
];
|
||||
}
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function parseNdp($output)
|
||||
{
|
||||
$items = [];
|
||||
foreach (preg_split('/\r?\n/', (string)$output) as $line) {
|
||||
if (preg_match('/^([0-9a-f:]+)\s+([0-9a-f:]+)\s+\S+\s+\S+\s+(\S+)$/i', trim($line), $m)) {
|
||||
$items[] = [
|
||||
'ip' => strtolower($m[1]),
|
||||
'mac' => strtolower($m[2]),
|
||||
'if' => $m[3],
|
||||
'source' => 'ndp'
|
||||
];
|
||||
}
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function parseLldp($output)
|
||||
{
|
||||
$items = [];
|
||||
$current = null;
|
||||
|
||||
foreach (preg_split('/\r?\n/', (string)$output) as $line) {
|
||||
if (preg_match('/^Interface:\s*([^,]+),/i', $line, $m)) {
|
||||
if ($current !== null) {
|
||||
$items[] = $current;
|
||||
}
|
||||
$current = [
|
||||
'if' => trim($m[1]),
|
||||
'sysname' => 'unknown',
|
||||
'port' => 'unknown',
|
||||
'source' => 'lldp'
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($current === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^\s*SysName:\s*(.+)$/i', $line, $m)) {
|
||||
$current['sysname'] = trim($m[1]);
|
||||
} elseif (preg_match('/^\s*PortID:\s*(.+)$/i', $line, $m)) {
|
||||
$current['port'] = trim($m[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($current !== null) {
|
||||
$items[] = $current;
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function buildTopology($interfaces, $arpItems, $ndpItems, $lldpItems, $maxNodes)
|
||||
{
|
||||
$nodes = [];
|
||||
$links = [];
|
||||
|
||||
foreach ($interfaces as $if) {
|
||||
$if = trim($if);
|
||||
if ($if === '') {
|
||||
continue;
|
||||
}
|
||||
$nodes['if:' . $if] = [
|
||||
'id' => 'if:' . $if,
|
||||
'label' => $if,
|
||||
'type' => 'interface'
|
||||
];
|
||||
}
|
||||
|
||||
$neighborItems = array_merge($arpItems, $ndpItems);
|
||||
foreach ($neighborItems as $item) {
|
||||
$ifId = 'if:' . $item['if'];
|
||||
$hostId = 'host:' . $item['ip'];
|
||||
if (!isset($nodes[$hostId])) {
|
||||
$nodes[$hostId] = [
|
||||
'id' => $hostId,
|
||||
'label' => $item['ip'],
|
||||
'ip' => $item['ip'],
|
||||
'mac' => $item['mac'],
|
||||
'type' => 'host',
|
||||
'source' => $item['source']
|
||||
];
|
||||
}
|
||||
$links[] = [
|
||||
'from' => $ifId,
|
||||
'to' => $hostId,
|
||||
'type' => $item['source']
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($lldpItems as $item) {
|
||||
$ifId = 'if:' . $item['if'];
|
||||
$devId = 'lldp:' . $item['if'] . ':' . md5($item['sysname'] . ':' . $item['port']);
|
||||
if (!isset($nodes[$devId])) {
|
||||
$nodes[$devId] = [
|
||||
'id' => $devId,
|
||||
'label' => $item['sysname'],
|
||||
'port' => $item['port'],
|
||||
'type' => 'lldp-neighbor',
|
||||
'source' => 'lldp'
|
||||
];
|
||||
}
|
||||
$links[] = [
|
||||
'from' => $ifId,
|
||||
'to' => $devId,
|
||||
'type' => 'lldp'
|
||||
];
|
||||
}
|
||||
|
||||
$nodes = array_values($nodes);
|
||||
if (count($nodes) > $maxNodes) {
|
||||
$nodes = array_slice($nodes, 0, $maxNodes);
|
||||
}
|
||||
|
||||
// Keep only links that still point to nodes after node capping.
|
||||
$allowedNodeIds = [];
|
||||
foreach ($nodes as $node) {
|
||||
$allowedNodeIds[$node['id']] = true;
|
||||
}
|
||||
|
||||
$links = array_values(array_filter($links, function ($link) use ($allowedNodeIds) {
|
||||
return isset($allowedNodeIds[$link['from']]) && isset($allowedNodeIds[$link['to']]);
|
||||
}));
|
||||
|
||||
return ['nodes' => $nodes, 'links' => $links];
|
||||
}
|
||||
|
||||
private function collectDiscoveryData()
|
||||
{
|
||||
$settings = $this->getSettings();
|
||||
if ((string)$settings->enabled === '0') {
|
||||
return ['status' => 'failed', 'message' => 'Topology mapper is disabled'];
|
||||
}
|
||||
|
||||
$backend = new Backend();
|
||||
$interfacesRaw = trim($backend->configdRun('topologymap interfaces'));
|
||||
$interfaces = preg_split('/\s+/', $interfacesRaw);
|
||||
|
||||
$arpItems = [];
|
||||
$ndpItems = [];
|
||||
$lldpItems = [];
|
||||
|
||||
if ((string)$settings->useArp === '1') {
|
||||
$arpItems = $this->parseArp($backend->configdRun('topologymap arp'));
|
||||
}
|
||||
|
||||
if ((string)$settings->useNdp === '1') {
|
||||
$ndpItems = $this->parseNdp($backend->configdRun('topologymap ndp'));
|
||||
}
|
||||
|
||||
if ((string)$settings->useLldp === '1') {
|
||||
$lldpItems = $this->parseLldp($backend->configdRun('topologymap lldp'));
|
||||
}
|
||||
|
||||
$maxNodes = (int)$settings->maxNodes;
|
||||
if ($maxNodes < 1) {
|
||||
$maxNodes = 500;
|
||||
}
|
||||
|
||||
$topology = $this->buildTopology($interfaces, $arpItems, $ndpItems, $lldpItems, $maxNodes);
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'settings' => $settings,
|
||||
'summary' => [
|
||||
'interfaces' => count(array_filter($interfaces)),
|
||||
'hosts' => count($arpItems) + count($ndpItems),
|
||||
'neighbors' => count($lldpItems),
|
||||
'nodes' => count($topology['nodes']),
|
||||
'links' => count($topology['links'])
|
||||
],
|
||||
'topology' => $topology
|
||||
];
|
||||
}
|
||||
|
||||
private function buildGeoDataset($topology)
|
||||
{
|
||||
$points = [];
|
||||
|
||||
foreach (($topology['nodes'] ?? []) as $node) {
|
||||
if (($node['type'] ?? '') !== 'host') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ip = (string)($node['ip'] ?? '');
|
||||
if (!$this->isPublicIp($ip)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Coordinates are intentionally null when no local geolocation provider is configured.
|
||||
$points[] = [
|
||||
'id' => $node['id'],
|
||||
'label' => $node['label'] ?? $ip,
|
||||
'ip' => $ip,
|
||||
'lat' => null,
|
||||
'lon' => null,
|
||||
'provider' => 'none'
|
||||
];
|
||||
}
|
||||
|
||||
return $points;
|
||||
}
|
||||
|
||||
public function discoverAction()
|
||||
{
|
||||
if (!$this->request->isPost()) {
|
||||
return ['status' => 'failed', 'message' => 'POST required'];
|
||||
}
|
||||
|
||||
$resp = $this->collectDiscoveryData();
|
||||
if (($resp['status'] ?? 'failed') !== 'ok') {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
$settings = $resp['settings'];
|
||||
$topology = $resp['topology'];
|
||||
$geoPoints = ((string)$settings->showGeoMap === '1') ? $this->buildGeoDataset($topology) : [];
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'summary' => $resp['summary'],
|
||||
'topology' => $topology,
|
||||
'geomap' => $geoPoints,
|
||||
'meta' => [
|
||||
'geo_enabled' => ((string)$settings->showGeoMap === '1') ? 'yes' : 'no',
|
||||
'geo_points' => count($geoPoints)
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function summaryAction()
|
||||
{
|
||||
$resp = $this->collectDiscoveryData();
|
||||
if (!is_array($resp) || ($resp['status'] ?? 'failed') !== 'ok') {
|
||||
return ['status' => 'failed'];
|
||||
}
|
||||
|
||||
return ['status' => 'ok', 'summary' => $resp['summary']];
|
||||
}
|
||||
|
||||
public function geomapAction()
|
||||
{
|
||||
$resp = $this->collectDiscoveryData();
|
||||
if (!is_array($resp) || ($resp['status'] ?? 'failed') !== 'ok') {
|
||||
return ['status' => 'failed'];
|
||||
}
|
||||
|
||||
if ((string)$resp['settings']->showGeoMap !== '1') {
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'enabled' => 'no',
|
||||
'points' => []
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'enabled' => 'yes',
|
||||
'points' => $this->buildGeoDataset($resp['topology'])
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace OPNsense\TopologyMap\Api;
|
||||
|
||||
use OPNsense\Base\ApiMutableModelControllerBase;
|
||||
|
||||
class SettingsController extends ApiMutableModelControllerBase
|
||||
{
|
||||
protected static $internalModelName = 'topologymap';
|
||||
protected static $internalModelClass = 'OPNsense\\TopologyMap\\TopologyMap';
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace OPNsense\TopologyMap;
|
||||
|
||||
class IndexController extends \OPNsense\Base\IndexController
|
||||
{
|
||||
public function indexAction()
|
||||
{
|
||||
$this->view->settings = $this->getForm('settings');
|
||||
$this->view->pick('OPNsense/TopologyMap/index');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<form>
|
||||
<field>
|
||||
<id>topologymap.general.enabled</id>
|
||||
<label>Enable topology mapper</label>
|
||||
<type>checkbox</type>
|
||||
<help>Enable topology discovery and API endpoints.</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>topologymap.general.useLldp</id>
|
||||
<label>Use LLDP discovery</label>
|
||||
<type>checkbox</type>
|
||||
<help>Use LLDP neighbors when lldpd is available.</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>topologymap.general.useArp</id>
|
||||
<label>Use ARP discovery</label>
|
||||
<type>checkbox</type>
|
||||
<help>Discover IPv4 neighbors from ARP table.</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>topologymap.general.useNdp</id>
|
||||
<label>Use NDP discovery</label>
|
||||
<type>checkbox</type>
|
||||
<help>Discover IPv6 neighbors from NDP table.</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>topologymap.general.maxNodes</id>
|
||||
<label>Maximum nodes</label>
|
||||
<type>text</type>
|
||||
<help>Hard limit for returned node count to protect UI performance.</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>topologymap.general.showGeoMap</id>
|
||||
<label>Enable geo map API output</label>
|
||||
<type>checkbox</type>
|
||||
<help>Expose geo map dataset for public/external IPs.</help>
|
||||
</field>
|
||||
</form>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<acl>
|
||||
<page-user-topologymap>
|
||||
<name>Services: Topology Map</name>
|
||||
<patterns>
|
||||
<pattern>ui/topologymap/*</pattern>
|
||||
<pattern>api/topologymap/*</pattern>
|
||||
</patterns>
|
||||
</page-user-topologymap>
|
||||
<page-dashboard-widget-topologymap>
|
||||
<name>Dashboard: Topology Map widget</name>
|
||||
<patterns>
|
||||
<pattern>api/topologymap/service/summary</pattern>
|
||||
<pattern>api/topologymap/service/geomap</pattern>
|
||||
</patterns>
|
||||
</page-dashboard-widget-topologymap>
|
||||
</acl>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<menu>
|
||||
<Services>
|
||||
<TopologyMap VisibleName="Topology Map" cssClass="fa fa-sitemap">
|
||||
<Settings url="/ui/topologymap"/>
|
||||
</TopologyMap>
|
||||
</Services>
|
||||
</menu>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace OPNsense\TopologyMap;
|
||||
|
||||
use OPNsense\Base\BaseModel;
|
||||
|
||||
class TopologyMap extends BaseModel
|
||||
{
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<model>
|
||||
<mount>//OPNsense/TopologyMap</mount>
|
||||
<version>1.0.0</version>
|
||||
<description>Topology mapper settings</description>
|
||||
<items>
|
||||
<general>
|
||||
<enabled type="BooleanField">
|
||||
<Default>1</Default>
|
||||
<Required>Y</Required>
|
||||
</enabled>
|
||||
<useLldp type="BooleanField">
|
||||
<Default>1</Default>
|
||||
<Required>Y</Required>
|
||||
</useLldp>
|
||||
<useArp type="BooleanField">
|
||||
<Default>1</Default>
|
||||
<Required>Y</Required>
|
||||
</useArp>
|
||||
<useNdp type="BooleanField">
|
||||
<Default>1</Default>
|
||||
<Required>Y</Required>
|
||||
</useNdp>
|
||||
<maxNodes type="IntegerField">
|
||||
<Default>500</Default>
|
||||
<MinimumValue>50</MinimumValue>
|
||||
<MaximumValue>5000</MaximumValue>
|
||||
<Required>Y</Required>
|
||||
</maxNodes>
|
||||
<showGeoMap type="BooleanField">
|
||||
<Default>1</Default>
|
||||
<Required>Y</Required>
|
||||
</showGeoMap>
|
||||
</general>
|
||||
</items>
|
||||
</model>
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
<script>
|
||||
$(document).ready(function () {
|
||||
function renderSummary(summary) {
|
||||
$('#summaryInterfaces').text(summary.interfaces || 0);
|
||||
$('#summaryHosts').text(summary.hosts || 0);
|
||||
$('#summaryNeighbors').text(summary.neighbors || 0);
|
||||
$('#summaryNodes').text(summary.nodes || 0);
|
||||
$('#summaryLinks').text(summary.links || 0);
|
||||
$('#summaryGeoPoints').text(summary.geoPoints || 0);
|
||||
}
|
||||
|
||||
function renderTable(selector, rows, cols) {
|
||||
var html = '<table class="table table-striped __nomb"><thead><tr>';
|
||||
for (var i = 0; i < cols.length; i++) {
|
||||
html += '<th>' + cols[i].label + '</th>';
|
||||
}
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
for (var r = 0; r < rows.length; r++) {
|
||||
html += '<tr>';
|
||||
for (var c = 0; c < cols.length; c++) {
|
||||
var key = cols[c].key;
|
||||
var value = rows[r][key] || '';
|
||||
html += '<td>' + $('<div/>').text(value).html() + '</td>';
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
$(selector).html(html);
|
||||
}
|
||||
|
||||
function loadData() {
|
||||
ajaxCall('/api/topologymap/service/discover', {}, function (data, status) {
|
||||
if (status !== 'success' || data['status'] !== 'ok') {
|
||||
$('#responseMsg').removeClass('hidden alert-info').addClass('alert-danger').html(data['message'] || '{{ lang._('Unable to load topology data.') }}');
|
||||
return;
|
||||
}
|
||||
|
||||
$('#responseMsg').addClass('hidden').removeClass('alert-danger').html('');
|
||||
var summary = data['summary'] || {};
|
||||
summary.geoPoints = (data['meta'] && data['meta']['geo_points']) ? data['meta']['geo_points'] : 0;
|
||||
renderSummary(summary);
|
||||
|
||||
var nodes = (data['topology'] && data['topology']['nodes']) ? data['topology']['nodes'] : [];
|
||||
var links = (data['topology'] && data['topology']['links']) ? data['topology']['links'] : [];
|
||||
|
||||
renderTable('#nodesTable', nodes, [
|
||||
{key: 'label', label: '{{ lang._('Node') }}'},
|
||||
{key: 'type', label: '{{ lang._('Type') }}'},
|
||||
{key: 'ip', label: '{{ lang._('IP') }}'},
|
||||
{key: 'mac', label: '{{ lang._('MAC') }}'},
|
||||
{key: 'source', label: '{{ lang._('Source') }}'}
|
||||
]);
|
||||
|
||||
renderTable('#linksTable', links, [
|
||||
{key: 'from', label: '{{ lang._('From') }}'},
|
||||
{key: 'to', label: '{{ lang._('To') }}'},
|
||||
{key: 'type', label: '{{ lang._('Type') }}'}
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
mapDataToFormUI({'frm_topologymap': '/api/topologymap/settings/get'});
|
||||
|
||||
$('#saveAct').click(function () {
|
||||
saveFormToEndpoint('/api/topologymap/settings/set', 'frm_topologymap', function () {
|
||||
$('#responseMsg').removeClass('hidden alert-danger').addClass('alert-info').html('{{ lang._('Settings saved.') }}');
|
||||
loadData();
|
||||
});
|
||||
});
|
||||
|
||||
$('#refreshAct').click(function () {
|
||||
loadData();
|
||||
});
|
||||
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="alert alert-info" role="alert">
|
||||
{{ lang._('Automatic topology mapping using LLDP, ARP and NDP discovery. Geo map output is available for future dashboard/map widgets.') }}
|
||||
</div>
|
||||
|
||||
<div class="alert hidden" role="alert" id="responseMsg"></div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="content-box tab-content table-responsive">
|
||||
<table class="table table-striped __nomb" style="margin-bottom:0">
|
||||
<tr><th class="listtopic">{{ lang._('Discovery Summary') }}</th></tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ lang._('Interfaces') }}:</strong> <span id="summaryInterfaces">0</span> |
|
||||
<strong>{{ lang._('Hosts') }}:</strong> <span id="summaryHosts">0</span> |
|
||||
<strong>{{ lang._('LLDP Neighbors') }}:</strong> <span id="summaryNeighbors">0</span> |
|
||||
<strong>{{ lang._('Nodes') }}:</strong> <span id="summaryNodes">0</span> |
|
||||
<strong>{{ lang._('Links') }}:</strong> <span id="summaryLinks">0</span> |
|
||||
<strong>{{ lang._('Geo Points') }}:</strong> <span id="summaryGeoPoints">0</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-12" style="margin-top: 10px;">
|
||||
{{ partial("layout_partials/base_form",['fields':settings,'id':'frm_topologymap'])}}
|
||||
</div>
|
||||
|
||||
<div class="col-md-12" style="margin-top: 10px;">
|
||||
<button class="btn btn-primary" id="saveAct" type="button"><b>{{ lang._('Save') }}</b></button>
|
||||
<button class="btn btn-default" id="refreshAct" type="button"><b>{{ lang._('Refresh Discovery') }}</b></button>
|
||||
</div>
|
||||
|
||||
<div class="row" style="margin-top: 10px;">
|
||||
<div class="col-md-6">
|
||||
<div class="content-box tab-content table-responsive">
|
||||
<table class="table table-striped __nomb" style="margin-bottom:0">
|
||||
<tr><th class="listtopic">{{ lang._('Nodes') }}</th></tr>
|
||||
<tr><td id="nodesTable"></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="content-box tab-content table-responsive">
|
||||
<table class="table table-striped __nomb" style="margin-bottom:0">
|
||||
<tr><th class="listtopic">{{ lang._('Links') }}</th></tr>
|
||||
<tr><td id="linksTable"></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
[lldp]
|
||||
command:command -v lldpctl >/dev/null 2>&1 && lldpctl 2>/dev/null || true
|
||||
type:script_output
|
||||
description:Get LLDP neighbors
|
||||
message:Getting LLDP neighbors
|
||||
errors:no
|
||||
|
||||
[arp]
|
||||
command:arp -an
|
||||
type:script_output
|
||||
description:Get ARP table
|
||||
message:Getting ARP table
|
||||
errors:no
|
||||
|
||||
[ndp]
|
||||
command:ndp -an
|
||||
type:script_output
|
||||
description:Get NDP table
|
||||
message:Getting NDP table
|
||||
errors:no
|
||||
|
||||
[interfaces]
|
||||
command:ifconfig -l
|
||||
type:script_output
|
||||
description:Get interface list
|
||||
message:Getting interface list
|
||||
errors:no
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<metadata>
|
||||
<topologymap>
|
||||
<filename>TopologyMap.js</filename>
|
||||
<endpoints>
|
||||
<endpoint>/api/topologymap/service/summary</endpoint>
|
||||
<endpoint>/api/topologymap/service/geomap</endpoint>
|
||||
</endpoints>
|
||||
<translations>
|
||||
<title>Topology Map</title>
|
||||
<interfaces>Interfaces</interfaces>
|
||||
<hosts>Hosts</hosts>
|
||||
<neighbors>LLDP Neighbors</neighbors>
|
||||
<nodes>Nodes</nodes>
|
||||
<links>Links</links>
|
||||
<geoPoints>Geo Points</geoPoints>
|
||||
</translations>
|
||||
</topologymap>
|
||||
</metadata>
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Copyright (C) 2026
|
||||
* All rights reserved.
|
||||
*/
|
||||
|
||||
export default class TopologyMap extends BaseTableWidget {
|
||||
constructor() {
|
||||
super();
|
||||
this.tickTimeout = 60;
|
||||
}
|
||||
|
||||
getMarkup() {
|
||||
let $container = $('<div></div>');
|
||||
$container.append(this.createTable('topologymap-table', {
|
||||
headerPosition: 'left'
|
||||
}));
|
||||
return $container;
|
||||
}
|
||||
|
||||
async onMarkupRendered() {
|
||||
const rows = [
|
||||
[[this.translations.interfaces], $('<span id="tm-interfaces">').prop('outerHTML')],
|
||||
[[this.translations.hosts], $('<span id="tm-hosts">').prop('outerHTML')],
|
||||
[[this.translations.neighbors], $('<span id="tm-neighbors">').prop('outerHTML')],
|
||||
[[this.translations.nodes], $('<span id="tm-nodes">').prop('outerHTML')],
|
||||
[[this.translations.links], $('<span id="tm-links">').prop('outerHTML')],
|
||||
[[this.translations.geoPoints], $('<span id="tm-geo-points">').prop('outerHTML')]
|
||||
];
|
||||
|
||||
super.updateTable('topologymap-table', rows);
|
||||
}
|
||||
|
||||
async onWidgetTick() {
|
||||
const summary = await this.ajaxCall('/api/topologymap/service/summary');
|
||||
const geomap = await this.ajaxCall('/api/topologymap/service/geomap');
|
||||
|
||||
if (!summary || summary.status !== 'ok' || !summary.summary) {
|
||||
['interfaces', 'hosts', 'neighbors', 'nodes', 'links'].forEach((name) => {
|
||||
$('#tm-' + name).text('-');
|
||||
});
|
||||
$('#tm-geo-points').text('-');
|
||||
return;
|
||||
}
|
||||
|
||||
$('#tm-interfaces').text(summary.summary.interfaces ?? '-');
|
||||
$('#tm-hosts').text(summary.summary.hosts ?? '-');
|
||||
$('#tm-neighbors').text(summary.summary.neighbors ?? '-');
|
||||
$('#tm-nodes').text(summary.summary.nodes ?? '-');
|
||||
$('#tm-links').text(summary.summary.links ?? '-');
|
||||
|
||||
if (geomap && geomap.status === 'ok' && Array.isArray(geomap.points)) {
|
||||
$('#tm-geo-points').text(geomap.points.length);
|
||||
} else {
|
||||
$('#tm-geo-points').text('0');
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue