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:
Victor Gorchilov
2022-12-09 13:40:39 +01:00
committed by GitHub
parent 5c87694b87
commit 52c2611e9d
30 changed files with 1395 additions and 628 deletions
+75 -66
View File
@@ -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()
+23 -3
View File
@@ -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
}
+113 -153
View File
@@ -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()
}
}