Add netbox_rack_concept plugin: rack blueprint planning
Introduces a NetBox 4.6 plugin for planning rack layouts (RackConcept, ConceptDevice) without creating real dcim.Rack/dcim.Device objects, so blueprints never appear in rack/device lists, utilization reports, or IPAM. Features: - Copy paths: real rack -> concept, concept -> concept (clone), and concept -> one or more real racks (deploy, with name patterning and dry-run support) - Manual concept creation via a dedicated "Rack Concepts" nav menu - Tenant and tenant group assignment on concepts and planned devices - Partial-width device placement (1/1-1/4) with slot-based collision detection, matching netbox_utilities' partial-width feature - Custom drag-and-drop elevation (netbox-reorder-rack operates on real Device querysets, which concepts don't have) with server-side validation of every move - Full REST API, filtering, CSV import, bulk edit, search integration - Optional, config-driven interop with netbox_utilities (partial-width field mapping) and netbox-reorder-rack, degrading gracefully when either plugin is absent Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
/* Concept rack elevations. Colours follow the NetBox theme via CSS variables so the
|
||||
elevation stays readable in both light and dark mode. */
|
||||
|
||||
.concept-elevation {
|
||||
margin: 0 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.concept-elevation-svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.concept-elevation .unit-slot {
|
||||
fill: var(--tblr-bg-surface-secondary, #f6f8fa);
|
||||
stroke: var(--tblr-border-color, #dadfe5);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.concept-elevation .unit-slot.drop-target {
|
||||
fill: var(--tblr-primary-lt, #d7e7fb);
|
||||
}
|
||||
|
||||
.concept-elevation .unit-label {
|
||||
font-size: 10px;
|
||||
fill: var(--tblr-secondary, #808f9c);
|
||||
}
|
||||
|
||||
.concept-elevation .rack-frame {
|
||||
stroke: var(--tblr-body-color, #35404a);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.concept-elevation .device-rect {
|
||||
stroke: rgba(0, 0, 0, 0.35);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.concept-elevation .device-label {
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.concept-elevation .opposite-hint {
|
||||
font-size: 9px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.concept-elevation .device-block.opposite .device-rect {
|
||||
stroke-dasharray: 3 2;
|
||||
}
|
||||
|
||||
.concept-elevation[data-editable="true"] .device-block:not(.opposite) {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.concept-elevation .device-block.dragging {
|
||||
opacity: 0.6;
|
||||
cursor: grabbing;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* Drag & drop for concept rack elevations.
|
||||
*
|
||||
* netbox-reorder-rack cannot be reused here: it moves dcim.Device rows, and a concept
|
||||
* deliberately has none. The moves are collected client-side and applied server-side in
|
||||
* a single transaction, so an invalid drop leaves the concept exactly as it was.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const container = document.getElementById('concept-elevations');
|
||||
if (!container || container.dataset.editable !== 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
const reorderUrl = container.dataset.reorderUrl;
|
||||
const startingUnit = parseInt(container.dataset.startingUnit, 10);
|
||||
const rackHeight = parseInt(container.dataset.uHeight, 10);
|
||||
const descUnits = container.dataset.descUnits === 'true';
|
||||
const statusEl = document.getElementById('concept-elevation-status');
|
||||
|
||||
function csrfToken() {
|
||||
const match = document.cookie.match(/(^|;)\s*csrftoken\s*=\s*([^;]+)/);
|
||||
return match ? decodeURIComponent(match[2]) : '';
|
||||
}
|
||||
|
||||
function setStatus(message, isError) {
|
||||
if (!statusEl) return;
|
||||
statusEl.textContent = message || '';
|
||||
statusEl.className = 'small mt-2 ' + (isError ? 'text-danger' : 'text-muted');
|
||||
}
|
||||
|
||||
/* Geometry is read back out of the rendered SVG so the server stays the single
|
||||
source of truth for sizes. */
|
||||
function geometry(svg) {
|
||||
const slot = svg.querySelector('.unit-slot');
|
||||
if (!slot) return null;
|
||||
return {
|
||||
unitHeight: slot.height.baseVal.value,
|
||||
rackX: slot.x.baseVal.value,
|
||||
rackWidth: slot.width.baseVal.value
|
||||
};
|
||||
}
|
||||
|
||||
function svgPoint(svg, event) {
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const scale = svg.viewBox.baseVal.width / rect.width;
|
||||
return {
|
||||
x: (event.clientX - rect.left) * scale,
|
||||
y: (event.clientY - rect.top) * scale
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
container.querySelectorAll('.concept-elevation').forEach(function (panel) {
|
||||
const svg = panel.querySelector('svg');
|
||||
const geo = geometry(svg);
|
||||
if (!geo) return;
|
||||
|
||||
panel.querySelectorAll('.device-block:not(.opposite)').forEach(function (block) {
|
||||
block.addEventListener('pointerdown', onPointerDown);
|
||||
|
||||
function onPointerDown(event) {
|
||||
// Let plain clicks through to the device link.
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
|
||||
const start = svgPoint(svg, event);
|
||||
const rect = block.querySelector('.device-rect');
|
||||
const originX = rect.x.baseVal.value;
|
||||
const deviceUnits = parseFloat(block.dataset.uHeight);
|
||||
const oldPosition = parseFloat(block.dataset.position);
|
||||
const fraction = parseInt(
|
||||
block.dataset.widthFraction || rect.dataset.widthFraction || '1', 10
|
||||
);
|
||||
let moved = false;
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
|
||||
block.classList.add('dragging');
|
||||
block.setPointerCapture(event.pointerId);
|
||||
|
||||
function onMove(moveEvent) {
|
||||
const now = svgPoint(svg, moveEvent);
|
||||
dx = now.x - start.x;
|
||||
dy = now.y - start.y;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
|
||||
moved = true;
|
||||
}
|
||||
const snappedY = Math.round(dy / geo.unitHeight) * geo.unitHeight;
|
||||
block.setAttribute('transform', 'translate(' + dx + ' ' + snappedY + ')');
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
block.removeEventListener('pointermove', onMove);
|
||||
block.removeEventListener('pointerup', onUp);
|
||||
block.removeEventListener('pointercancel', onUp);
|
||||
block.classList.remove('dragging');
|
||||
|
||||
if (!moved) {
|
||||
// Treat it as a click: follow the device link.
|
||||
block.removeAttribute('transform');
|
||||
const link = block.querySelector('a');
|
||||
if (link) window.location = link.getAttribute('href');
|
||||
return;
|
||||
}
|
||||
|
||||
const unitDelta = Math.round(dy / geo.unitHeight) * (descUnits ? 1 : -1);
|
||||
const lowest = startingUnit;
|
||||
const highest = startingUnit + rackHeight - deviceUnits;
|
||||
const newPosition = clamp(oldPosition + unitDelta, lowest, highest);
|
||||
|
||||
let horizontal = 0;
|
||||
if (fraction > 1) {
|
||||
const newX = originX + dx;
|
||||
const slotWidth = geo.rackWidth / fraction;
|
||||
horizontal = clamp(
|
||||
Math.round((newX - geo.rackX) / slotWidth), 0, fraction - 1
|
||||
);
|
||||
}
|
||||
|
||||
submit(block, newPosition, fraction > 1 ? horizontal : null);
|
||||
}
|
||||
|
||||
block.addEventListener('pointermove', onMove);
|
||||
block.addEventListener('pointerup', onUp);
|
||||
block.addEventListener('pointercancel', onUp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function submit(block, position, horizontalPosition) {
|
||||
const move = {
|
||||
id: parseInt(block.dataset.deviceId, 10),
|
||||
position: position
|
||||
};
|
||||
if (horizontalPosition !== null) {
|
||||
move.horizontal_position = horizontalPosition;
|
||||
}
|
||||
|
||||
setStatus('Saving…', false);
|
||||
|
||||
fetch(reorderUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken()
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ devices: [move] })
|
||||
})
|
||||
.then(function (response) {
|
||||
return response.json().then(function (body) {
|
||||
return { ok: response.ok, body: body };
|
||||
});
|
||||
})
|
||||
.then(function (result) {
|
||||
if (result.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
block.removeAttribute('transform');
|
||||
setStatus(result.body.error || 'The move was rejected.', true);
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
block.removeAttribute('transform');
|
||||
setStatus('The move could not be saved.', true);
|
||||
});
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user