Feature: Add customisable icons in topology (#193)
* [up] add config for custom images, better js * [up] use bundler to bundle js * [fix] remove domcontentloaded since defer is used * [fix] formatting with black * [fix] images drf api * [up] add support for custom icons on additional roles: power panel, power feed, circuit * [up] optimize topology generation by using `_id` when trying to access pk of related object * [fix] kebab case instead of slugification * [fix] use empty queryset in api views & change coord save logic
This commit is contained in:
@@ -1,87 +1,96 @@
|
||||
const esbuild = require('esbuild');
|
||||
const { sassPlugin } = require('esbuild-sass-plugin');
|
||||
const esbuild = require('esbuild')
|
||||
const { sassPlugin } = require('esbuild-sass-plugin')
|
||||
|
||||
const options = {
|
||||
bundle: true,
|
||||
minify: true,
|
||||
sourcemap: 'external',
|
||||
sourcesContent: false,
|
||||
logLevel: 'error',
|
||||
};
|
||||
|
||||
const ARGS = process.argv.slice(2);
|
||||
logLevel: 'error'
|
||||
}
|
||||
|
||||
const ARGS = process.argv.slice(2)
|
||||
const noCache = ARGS.includes('--no-cache')
|
||||
|
||||
async function bundleScripts() {
|
||||
const entryPoints = {
|
||||
'app': 'js/home.js'
|
||||
};
|
||||
try {
|
||||
const result = await esbuild.build({
|
||||
...options,
|
||||
outdir: '../static/netbox_topology_views/js/',
|
||||
entryPoints,
|
||||
target: 'es2016',
|
||||
});
|
||||
if (result.errors.length === 0) {
|
||||
for (const [targetName, sourceName] of Object.entries(entryPoints)) {
|
||||
const source = sourceName.split('/')[1];
|
||||
console.log(`✅ Bundled source file '${source}' to '${targetName}.js'`);
|
||||
}
|
||||
const entryPoints = {
|
||||
app: 'js/home.js',
|
||||
images: 'js/images.js'
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await esbuild.build({
|
||||
...options,
|
||||
outdir: '../static/netbox_topology_views/js/',
|
||||
entryPoints,
|
||||
target: 'es2016'
|
||||
})
|
||||
if (result.errors.length !== 0) return
|
||||
|
||||
for (const [targetName, sourceName] of Object.entries(entryPoints)) {
|
||||
const source = sourceName.split('/').pop() // take last element
|
||||
console.log(
|
||||
`✅ Bundled source file '${source}' to '${targetName}.js'`
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function bundleStyles() {
|
||||
try {
|
||||
const entryPoints = {
|
||||
'vendor': 'css/_external.scss',
|
||||
'app': 'css/app.scss',
|
||||
};
|
||||
const pluginOptions = { outputStyle: 'compressed' };
|
||||
// Allow cache disabling.
|
||||
if (ARGS.includes('--no-cache')) {
|
||||
pluginOptions.cache = false;
|
||||
}
|
||||
let result = await esbuild.build({
|
||||
...options,
|
||||
outdir: '../static/netbox_topology_views/css/',
|
||||
// Disable sourcemaps for CSS/SCSS files, see #7068
|
||||
sourcemap: false,
|
||||
entryPoints,
|
||||
plugins: [sassPlugin(pluginOptions)],
|
||||
loader: {
|
||||
'.eot': 'file',
|
||||
'.woff': 'file',
|
||||
'.woff2': 'file',
|
||||
'.svg': 'file',
|
||||
'.ttf': 'file',
|
||||
},
|
||||
});
|
||||
if (result.errors.length === 0) {
|
||||
for (const [targetName, sourceName] of Object.entries(entryPoints)) {
|
||||
const source = sourceName.split('/')[1];
|
||||
console.log(`✅ Bundled source file '${source}' to '${targetName}.css'`);
|
||||
const entryPoints = {
|
||||
vendor: 'css/_external.scss',
|
||||
app: 'css/app.scss'
|
||||
}
|
||||
const pluginOptions = { outputStyle: 'compressed' }
|
||||
// Allow cache disabling.
|
||||
if (noCache) {
|
||||
pluginOptions.cache = false
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await esbuild.build({
|
||||
...options,
|
||||
outdir: '../static/netbox_topology_views/css/',
|
||||
// Disable sourcemaps for CSS/SCSS files, see #7068
|
||||
sourcemap: false,
|
||||
entryPoints,
|
||||
plugins: [sassPlugin(pluginOptions)],
|
||||
loader: {
|
||||
'.eot': 'file',
|
||||
'.woff': 'file',
|
||||
'.woff2': 'file',
|
||||
'.svg': 'file',
|
||||
'.ttf': 'file'
|
||||
}
|
||||
})
|
||||
if (result.errors.length === 0) {
|
||||
for (const [targetName, sourceName] of Object.entries(
|
||||
entryPoints
|
||||
)) {
|
||||
const source = sourceName.split('/')[1]
|
||||
console.log(
|
||||
`✅ Bundled source file '${source}' to '${targetName}.css'`
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function bundleAll() {
|
||||
if (ARGS.includes('--styles')) {
|
||||
// Only run style jobs.
|
||||
return await bundleStyles();
|
||||
} else if (ARGS.includes('--scripts')) {
|
||||
// Only run script jobs.
|
||||
return await bundleScripts();
|
||||
// Only run style jobs.
|
||||
return await bundleStyles()
|
||||
}
|
||||
await bundleStyles();
|
||||
await bundleScripts();
|
||||
if (ARGS.includes('--scripts')) {
|
||||
// Only run script jobs.
|
||||
return await bundleScripts()
|
||||
}
|
||||
await bundleStyles()
|
||||
await bundleScripts()
|
||||
}
|
||||
|
||||
bundleAll();
|
||||
|
||||
bundleAll()
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
#visgraph {
|
||||
height: 70vh;
|
||||
}
|
||||
height: 70vh;
|
||||
}
|
||||
|
||||
html[data-netbox-color-mode=dark] #visgraph {
|
||||
background-color: #212529;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.image-dropdown img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.image-dropdown-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
|
||||
padding-inline: 0.5rem;
|
||||
width: 50vw;
|
||||
max-width: 32rem;
|
||||
|
||||
> img {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export const getCookie = (name) => {
|
||||
if (!document.cookie) return
|
||||
|
||||
let cookieValue = null
|
||||
const cookies = document.cookie.split(';')
|
||||
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim()
|
||||
// Does this cookie string begin with the name we want?
|
||||
if (cookie.substring(0, name.length + 1) === name + '=') {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return cookieValue
|
||||
}
|
||||
@@ -1,17 +1,8 @@
|
||||
import { DataSet } from "vis-data/esnext";
|
||||
import { Network } from "vis-network/esnext";
|
||||
//import 'vis-util';
|
||||
import { DataSet } from 'vis-data/esnext'
|
||||
import { Network } from 'vis-network/esnext'
|
||||
import { getCookie } from './csrftoken.js'
|
||||
|
||||
|
||||
var graph = null;
|
||||
var container = null;
|
||||
var downloadButton = null;
|
||||
const MIME_TYPE = "image/png";
|
||||
var canvas = null;
|
||||
var csrftoken = null;
|
||||
var nodes = new DataSet();
|
||||
var edges = new DataSet();
|
||||
var options = {
|
||||
const options = {
|
||||
interaction: {
|
||||
hover: true,
|
||||
hoverConnectedEdges: true,
|
||||
@@ -19,18 +10,22 @@ var options = {
|
||||
},
|
||||
nodes: {
|
||||
shape: 'image',
|
||||
brokenImage: '../../static/netbox_topology_views/img/role-unknown.png',
|
||||
brokenImage: brokenImage ?? '',
|
||||
size: 35,
|
||||
font: {
|
||||
multi: 'md',
|
||||
face: 'helvetica',
|
||||
},
|
||||
color:
|
||||
document.documentElement.dataset.netboxColorMode === 'dark'
|
||||
? '#fff'
|
||||
: '#000'
|
||||
}
|
||||
},
|
||||
edges: {
|
||||
length: 100,
|
||||
width: 2,
|
||||
font: {
|
||||
face: 'helvetica',
|
||||
face: 'helvetica'
|
||||
},
|
||||
shadow: {
|
||||
enabled: true
|
||||
@@ -39,149 +34,114 @@ var options = {
|
||||
physics: {
|
||||
solver: 'forceAtlas2Based'
|
||||
}
|
||||
};
|
||||
var coord_save_checkbox = null;
|
||||
var htmlElement = null;
|
||||
|
||||
export function getCookie(name) {
|
||||
var cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
var cookies = document.cookie.split(';');
|
||||
for (var i = 0; i < cookies.length; i++) {
|
||||
var cookie = cookies[i].trim();
|
||||
// Does this cookie string begin with the name we want?
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
};
|
||||
|
||||
|
||||
export function htmlTitle(html) {
|
||||
container = document.createElement("div");
|
||||
container.innerHTML = html;
|
||||
return container;
|
||||
};
|
||||
|
||||
export function addEdge(item) {
|
||||
item.title = htmlTitle(item.title);
|
||||
edges.add(item);
|
||||
};
|
||||
|
||||
export function addNode(item) {
|
||||
item.title = htmlTitle(item.title);
|
||||
nodes.add(item);
|
||||
}
|
||||
|
||||
export function iniPlotboxIndex() {
|
||||
csrftoken = getCookie('csrftoken');
|
||||
container = document.getElementById('visgraph');
|
||||
htmlElement = document.getElementsByTagName("html")[0];
|
||||
downloadButton = document.getElementById('btnDownloadImage');
|
||||
handleLoadData();
|
||||
btnFullView = document.getElementById('btnFullView');
|
||||
coord_save_checkbox = document.getElementById('id_save_coords');
|
||||
};
|
||||
// Load CSRF token
|
||||
const csrftoken = getCookie('csrftoken')
|
||||
|
||||
export function performGraphDownload() {
|
||||
var tempDownloadLink = document.createElement('a');
|
||||
var generatedImageUrl = canvas.toDataURL(MIME_TYPE);
|
||||
// Render vis graph
|
||||
let graph = null // vis graph instance
|
||||
|
||||
tempDownloadLink.href = generatedImageUrl;
|
||||
tempDownloadLink.download = "topology";
|
||||
document.body.appendChild(tempDownloadLink);
|
||||
tempDownloadLink.click();
|
||||
document.body.removeChild(tempDownloadLink);
|
||||
};
|
||||
const container = document.querySelector('#visgraph')
|
||||
const coordSaveCheckbox = document.querySelector('#id_save_coords')
|
||||
;(function handleLoadData() {
|
||||
if (!topologyData) return
|
||||
|
||||
export function handleLoadData() {
|
||||
if (topology_data !== null) {
|
||||
|
||||
if (htmlElement.dataset.netboxColorMode == "dark") {
|
||||
options.nodes.font.color = "#fff";
|
||||
}
|
||||
|
||||
graph = null;
|
||||
nodes = new DataSet();
|
||||
edges = new DataSet();
|
||||
graph = new Network(container, { nodes: nodes, edges: edges }, options);
|
||||
|
||||
topology_data.edges.forEach(addEdge);
|
||||
topology_data.nodes.forEach(addNode);
|
||||
|
||||
graph.fit();
|
||||
canvas = document.getElementById('visgraph').getElementsByTagName('canvas')[0];
|
||||
|
||||
downloadButton.onclick = function(e) { performGraphDownload(); return false; };
|
||||
|
||||
graph.on("dragEnd", function (params) {
|
||||
dragged = this.getPositions(params.nodes);
|
||||
|
||||
if (coord_save_checkbox.checked) {
|
||||
if (Object.keys(dragged).length !== 0) {
|
||||
for (dragged_device in dragged) {
|
||||
var node_id = dragged_device;
|
||||
|
||||
var url = "/api/plugins/netbox_topology_views/save-coords/save_coords/";
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("PATCH", url);
|
||||
xhr.setRequestHeader('X-CSRFToken', csrftoken );
|
||||
xhr.setRequestHeader("Accept", "application/json");
|
||||
xhr.setRequestHeader("Content-Type", "application/json");
|
||||
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState === 4) {
|
||||
console.log(xhr.status);
|
||||
}};
|
||||
|
||||
var data = JSON.stringify({
|
||||
'node_id': node_id,
|
||||
'x': dragged[node_id].x,
|
||||
'y': dragged[node_id].y});
|
||||
|
||||
xhr.send(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
graph.on("doubleClick", function (params) {
|
||||
let selected_devices = params.nodes;
|
||||
for (let selected_device in selected_devices) {
|
||||
let url = ""
|
||||
if(String(selected_devices[selected_device]).startsWith("c")) {
|
||||
cid = selected_devices[selected_device].substring(1);
|
||||
url = "/circuits/circuits/" + cid + "/";
|
||||
}
|
||||
else if (String(selected_devices[selected_device]).startsWith("p")) {
|
||||
cid = selected_devices[selected_device].substring(1);
|
||||
url = "/dcim/power-panels/" + cid + "/";
|
||||
}
|
||||
else if (String(selected_devices[selected_device]).startsWith("f")) {
|
||||
cid = selected_devices[selected_device].substring(1);
|
||||
url = "/dcim/power-feeds/" + cid + "/";
|
||||
}
|
||||
else {
|
||||
url = "/dcim/devices/" + selected_devices[selected_device] + "/";
|
||||
}
|
||||
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
|
||||
});
|
||||
function htmlTitle(text) {
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = text
|
||||
return container
|
||||
}
|
||||
};
|
||||
|
||||
export function load_doc() {
|
||||
if (document.readyState !== 'loading') {
|
||||
iniPlotboxIndex();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', iniPlotboxIndex);
|
||||
}
|
||||
};
|
||||
const nodes = new DataSet(
|
||||
topologyData.nodes.map((node) => ({
|
||||
...node,
|
||||
title: htmlTitle(node.title)
|
||||
}))
|
||||
)
|
||||
|
||||
const edges = new DataSet(
|
||||
topologyData.edges.map((node) => ({
|
||||
...node,
|
||||
title: htmlTitle(node.title)
|
||||
}))
|
||||
)
|
||||
graph = new Network(container, { nodes, edges }, options)
|
||||
graph.fit()
|
||||
|
||||
load_doc();
|
||||
graph.on('dragEnd', (params) => {
|
||||
if (!coordSaveCheckbox.checked) return
|
||||
|
||||
Promise.allSettled(
|
||||
Object.entries(graph.getPositions(params.nodes)).map(
|
||||
async ([nodeId, nodePosition]) => {
|
||||
const res = await fetch(
|
||||
'/api/plugins/netbox_topology_views/save-coords/',
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'X-CSRFToken': csrftoken,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
node_id: nodeId,
|
||||
x: nodePosition.x,
|
||||
y: nodePosition.y
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
console.log(nodeId, res.status, res.statusText)
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
graph.on('doubleClick', (params) => {
|
||||
params.nodes.forEach((node) => {
|
||||
window.open(nodes.get(node).href, '_blank')
|
||||
})
|
||||
})
|
||||
})()
|
||||
|
||||
// Download Graph
|
||||
const MIME_TYPE = 'image/png'
|
||||
|
||||
const downloadButton = document.querySelector('#btnDownloadImage')
|
||||
downloadButton.addEventListener('click', (e) => {
|
||||
performGraphDownload()
|
||||
})
|
||||
|
||||
function performGraphDownload() {
|
||||
const canvas = container.querySelector('canvas')
|
||||
const tempDownloadLink = document.createElement('a')
|
||||
const generatedImageUrl = canvas.toDataURL(MIME_TYPE)
|
||||
|
||||
tempDownloadLink.href = generatedImageUrl
|
||||
tempDownloadLink.download = 'topology'
|
||||
document.body.appendChild(tempDownloadLink)
|
||||
tempDownloadLink.click()
|
||||
document.body.removeChild(tempDownloadLink)
|
||||
}
|
||||
|
||||
// Theme switching
|
||||
const observer = new MutationObserver((mutations) =>
|
||||
mutations.forEach((mutation) => {
|
||||
if (
|
||||
!graph ||
|
||||
mutation.type !== 'attributes' ||
|
||||
mutation.attributeName !== 'data-netbox-color-mode' ||
|
||||
!(mutation.target instanceof HTMLElement)
|
||||
)
|
||||
return
|
||||
const { netboxColorMode } = mutation.target.dataset
|
||||
options.nodes.font.color = netboxColorMode === 'dark' ? '#fff' : '#000'
|
||||
graph.setOptions(options)
|
||||
})
|
||||
)
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-netbox-color-mode']
|
||||
})
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { getCookie } from './csrftoken.js'
|
||||
import { toast } from './toast.js'
|
||||
|
||||
const mapping = {}
|
||||
const csrftoken = getCookie('csrftoken')
|
||||
|
||||
document.querySelector('form#images').addEventListener('submit', async (e) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
const res = await fetch('/api/plugins/netbox_topology_views/images/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(mapping),
|
||||
headers: {
|
||||
'X-CSRFToken': csrftoken,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
toast.success('Saved settings')
|
||||
} catch (err) {
|
||||
console.dir(err)
|
||||
toast.error(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
document.querySelectorAll('form#images .dropdown-menu img').forEach((el) => {
|
||||
el.addEventListener('click', (e) => {
|
||||
if (!(e.currentTarget instanceof HTMLElement)) return
|
||||
const {
|
||||
dataset: { role, image }
|
||||
} = e.currentTarget
|
||||
|
||||
mapping[role] = image
|
||||
|
||||
const button = e.currentTarget
|
||||
.closest('.dropdown')
|
||||
?.querySelector(`#dropdownMenuButton${role}`)
|
||||
if (button) button.innerHTML = `<img src="${image}" />`
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
export const toast = {
|
||||
success: (message) => {
|
||||
const el = document.querySelector('#topology-plugin-success-toast')
|
||||
if (!el) return console.error('Could not find toast component!')
|
||||
const content = el.querySelector('span')
|
||||
content.textContent = message
|
||||
const toast = new window.Toast(el)
|
||||
toast.show()
|
||||
},
|
||||
error: (message) => {
|
||||
const el = document.querySelector('#topology-plugin-error-toast')
|
||||
if (!el) return console.error('Could not find toast component!')
|
||||
const content = el.querySelector('span')
|
||||
content.textContent = message
|
||||
const toast = new window.Toast(el)
|
||||
toast.show()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user