Add location grouping with cola layout

This commit is contained in:
Ian Gulliver
2026-01-24 15:04:42 -08:00
parent c662ff80f4
commit 8b50762c92
12 changed files with 241 additions and 10 deletions

BIN
.config.yaml.swp Normal file

Binary file not shown.

View File

@@ -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
View 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
View 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
View File

@@ -0,0 +1,11 @@
locations:
stage:
children:
upstage:
nodes:
- lighting-2
downstage:
children:
rack-lighting-1:
nodes:
- lighting-1

2
go.mod
View File

@@ -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
View File

@@ -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
View File

@@ -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

File diff suppressed because one or more lines are too long

8
static/cytoscape-cola.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -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: {

View File

@@ -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()