Add location grouping with cola layout
This commit is contained in:
BIN
.config.yaml.swp
Normal file
BIN
.config.yaml.swp
Normal file
Binary file not shown.
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
func main() {
|
||||
iface := flag.String("i", "", "interface to use")
|
||||
configFile := flag.String("config", "", "path to YAML config file")
|
||||
noARP := flag.Bool("no-arp", false, "disable ARP discovery")
|
||||
noLLDP := flag.Bool("no-lldp", false, "disable LLDP discovery")
|
||||
noSNMP := flag.Bool("no-snmp", false, "disable SNMP discovery")
|
||||
@@ -35,6 +36,7 @@ func main() {
|
||||
|
||||
t := tendrils.New()
|
||||
t.Interface = *iface
|
||||
t.ConfigFile = *configFile
|
||||
t.DisableARP = *noARP
|
||||
t.DisableLLDP = *noLLDP
|
||||
t.DisableSNMP = *noSNMP
|
||||
|
||||
21
config.example.yaml
Normal file
21
config.example.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
locations:
|
||||
stage:
|
||||
children:
|
||||
upstage:
|
||||
nodes:
|
||||
- lighting-1
|
||||
downstage:
|
||||
nodes:
|
||||
- lighting-2
|
||||
house:
|
||||
children:
|
||||
house_left:
|
||||
nodes:
|
||||
- audio
|
||||
house_right:
|
||||
nodes:
|
||||
- video
|
||||
booth:
|
||||
nodes:
|
||||
- qlab
|
||||
- satellite-1
|
||||
39
config.go
Normal file
39
config.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package tendrils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Locations map[string]*Location `yaml:"locations" json:"locations"`
|
||||
}
|
||||
|
||||
type Location struct {
|
||||
Nodes []string `yaml:"nodes,omitempty" json:"nodes,omitempty"`
|
||||
Children map[string]*Location `yaml:"children,omitempty" json:"children,omitempty"`
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
if path == "" {
|
||||
return &Config{}, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) ToJSON() ([]byte, error) {
|
||||
return json.Marshal(c)
|
||||
}
|
||||
11
config.yaml
Normal file
11
config.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
locations:
|
||||
stage:
|
||||
children:
|
||||
upstage:
|
||||
nodes:
|
||||
- lighting-2
|
||||
downstage:
|
||||
children:
|
||||
rack-lighting-1:
|
||||
nodes:
|
||||
- lighting-1
|
||||
2
go.mod
2
go.mod
@@ -8,10 +8,12 @@ require (
|
||||
github.com/gosnmp/gosnmp v1.42.1
|
||||
github.com/miekg/dns v1.1.72
|
||||
go.jetify.com/typeid v1.3.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/gofrs/uuid/v5 v5.2.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
|
||||
10
go.sum
10
go.sum
@@ -1,3 +1,4 @@
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw=
|
||||
@@ -10,10 +11,16 @@ github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF
|
||||
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
||||
github.com/gosnmp/gosnmp v1.42.1 h1:MEJxhpC5v1coL3tFRix08PYmky9nyb1TLRRgJAmXm8A=
|
||||
github.com/gosnmp/gosnmp v1.42.1/go.mod h1:CxVS6bXqmWZlafUj9pZUnQX5e4fAltqPcijxWpCitDo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.jetify.com/typeid v1.3.0 h1:fuWV7oxO4mSsgpxwhaVpFXgt0IfjogR29p+XAjDCVKY=
|
||||
@@ -40,6 +47,9 @@ golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapK
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
10
http.go
10
http.go
@@ -24,6 +24,7 @@ func (t *Tendrils) startHTTPServer() {
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/status", t.handleAPIStatus)
|
||||
mux.HandleFunc("/api/config", t.handleAPIConfig)
|
||||
mux.Handle("/", http.FileServer(http.Dir("static")))
|
||||
|
||||
log.Printf("[http] listening on %s", t.HTTPPort)
|
||||
@@ -40,6 +41,15 @@ func (t *Tendrils) handleAPIStatus(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
|
||||
func (t *Tendrils) handleAPIConfig(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if t.config == nil {
|
||||
json.NewEncoder(w).Encode(&Config{})
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(t.config)
|
||||
}
|
||||
|
||||
func (t *Tendrils) GetStatus() *StatusResponse {
|
||||
return &StatusResponse{
|
||||
Nodes: t.getNodes(),
|
||||
|
||||
4
static/cola.min.js
vendored
Normal file
4
static/cola.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8
static/cytoscape-cola.min.js
vendored
Normal file
8
static/cytoscape-cola.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -47,7 +47,10 @@
|
||||
<div id="cy"></div>
|
||||
|
||||
<script src="cytoscape.min.js"></script>
|
||||
<script src="cola.min.js"></script>
|
||||
<script src="cytoscape-cola.min.js"></script>
|
||||
<script>
|
||||
cytoscape.use(cytoscapeCola);
|
||||
let cy;
|
||||
|
||||
function getLabel(node) {
|
||||
@@ -55,31 +58,73 @@
|
||||
return '??';
|
||||
}
|
||||
|
||||
function getNodeIdentifiers(node) {
|
||||
const ids = [];
|
||||
if (node.names) {
|
||||
node.names.forEach(n => ids.push(n.toLowerCase()));
|
||||
}
|
||||
if (node.interfaces) {
|
||||
node.interfaces.forEach(iface => {
|
||||
if (iface.mac) ids.push(iface.mac.toLowerCase());
|
||||
});
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function isSwitch(node) {
|
||||
return !!(node.poe_budget);
|
||||
}
|
||||
|
||||
function buildLocationIndex(locations, parentId, nodeToLocation, locationMeta) {
|
||||
if (!locations) return;
|
||||
for (const [name, loc] of Object.entries(locations)) {
|
||||
const locId = 'loc_' + name.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
locationMeta.set(locId, { name, parentId });
|
||||
|
||||
if (loc.nodes) {
|
||||
loc.nodes.forEach(nodeRef => {
|
||||
nodeToLocation.set(nodeRef.toLowerCase(), locId);
|
||||
});
|
||||
}
|
||||
|
||||
if (loc.children) {
|
||||
buildLocationIndex(loc.children, locId, nodeToLocation, locationMeta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getLocationChain(locId, locationMeta) {
|
||||
const chain = [];
|
||||
let current = locId;
|
||||
while (current) {
|
||||
chain.push(current);
|
||||
const meta = locationMeta.get(current);
|
||||
current = meta ? meta.parentId : null;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
function doLayout() {
|
||||
cy.layout({
|
||||
name: 'cose',
|
||||
name: 'cola',
|
||||
animate: false,
|
||||
padding: 50,
|
||||
nodeDimensionsIncludeLabels: true,
|
||||
avoidOverlap: true,
|
||||
avoidOverlapPadding: 20,
|
||||
nodeRepulsion: 100000,
|
||||
idealEdgeLength: 200,
|
||||
edgeElasticity: 100,
|
||||
gravity: 0.1,
|
||||
numIter: 2000,
|
||||
nodeSpacing: 40,
|
||||
edgeLength: 200,
|
||||
fit: true,
|
||||
randomize: true
|
||||
}).run();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const resp = await fetch('/api/status');
|
||||
const data = await resp.json();
|
||||
const [statusResp, configResp] = await Promise.all([
|
||||
fetch('/api/status'),
|
||||
fetch('/api/config')
|
||||
]);
|
||||
const data = await statusResp.json();
|
||||
const config = await configResp.json();
|
||||
|
||||
const nodes = data.nodes || [];
|
||||
const links = data.links || [];
|
||||
@@ -91,20 +136,61 @@
|
||||
const idMap = new Map();
|
||||
const switchIds = new Set();
|
||||
|
||||
const nodeToLocation = new Map();
|
||||
const locationMeta = new Map();
|
||||
buildLocationIndex(config.locations, null, nodeToLocation, locationMeta);
|
||||
|
||||
nodes.forEach((n, i) => {
|
||||
const id = 'n' + i;
|
||||
idMap.set(n.typeid, id);
|
||||
if (isSwitch(n)) switchIds.add(id);
|
||||
});
|
||||
|
||||
const usedLocations = new Set();
|
||||
const nodeParents = new Map();
|
||||
|
||||
nodes.forEach((n, i) => {
|
||||
const id = 'n' + i;
|
||||
const identifiers = getNodeIdentifiers(n);
|
||||
for (const ident of identifiers) {
|
||||
if (nodeToLocation.has(ident)) {
|
||||
const locId = nodeToLocation.get(ident);
|
||||
nodeParents.set(id, locId);
|
||||
getLocationChain(locId, locationMeta).forEach(l => usedLocations.add(l));
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const sortedLocations = Array.from(usedLocations).sort((a, b) => {
|
||||
const chainA = getLocationChain(a, locationMeta).length;
|
||||
const chainB = getLocationChain(b, locationMeta).length;
|
||||
return chainA - chainB;
|
||||
});
|
||||
|
||||
sortedLocations.forEach(locId => {
|
||||
const meta = locationMeta.get(locId);
|
||||
elements.push({
|
||||
data: {
|
||||
id: locId,
|
||||
label: meta.name,
|
||||
parent: meta.parentId && usedLocations.has(meta.parentId) ? meta.parentId : null,
|
||||
isLocation: true
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
nodes.forEach((n, i) => {
|
||||
const id = 'n' + i;
|
||||
const sw = switchIds.has(id);
|
||||
const parent = nodeParents.get(id) || null;
|
||||
|
||||
elements.push({
|
||||
data: {
|
||||
id: id,
|
||||
label: getLabel(n),
|
||||
isSwitch: sw
|
||||
isSwitch: sw,
|
||||
parent: parent
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -158,6 +244,35 @@
|
||||
'height': 50
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'node[?isLocation]',
|
||||
style: {
|
||||
'background-color': '#333',
|
||||
'background-opacity': 0.8,
|
||||
'border-width': 2,
|
||||
'border-color': '#666',
|
||||
'text-valign': 'top',
|
||||
'text-halign': 'center',
|
||||
'text-margin-y': 10,
|
||||
'font-size': 16,
|
||||
'font-weight': 'bold',
|
||||
'color': '#fff',
|
||||
'padding': 30,
|
||||
'shape': 'round-rectangle'
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: ':parent',
|
||||
style: {
|
||||
'background-opacity': 0.5,
|
||||
'border-width': 2,
|
||||
'border-color': '#666',
|
||||
'text-valign': 'top',
|
||||
'text-halign': 'center',
|
||||
'text-margin-y': 10,
|
||||
'padding': 30
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
|
||||
@@ -34,8 +34,10 @@ type Tendrils struct {
|
||||
nodes *Nodes
|
||||
artnet *ArtNetNodes
|
||||
danteFlows *DanteFlows
|
||||
config *Config
|
||||
|
||||
Interface string
|
||||
ConfigFile string
|
||||
DisableARP bool
|
||||
DisableLLDP bool
|
||||
DisableSNMP bool
|
||||
@@ -83,6 +85,13 @@ func (t *Tendrils) Run() {
|
||||
}
|
||||
}()
|
||||
|
||||
cfg, err := LoadConfig(t.ConfigFile)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] failed to load config: %v", err)
|
||||
} else {
|
||||
t.config = cfg
|
||||
}
|
||||
|
||||
t.populateLocalAddresses()
|
||||
t.startHTTPServer()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user