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:
2026-09-16 09:00:26 +02:00
co-authored by Claude Sonnet 5
commit 17cf94ac0d
34 changed files with 3821 additions and 0 deletions
@@ -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);
});
}
})();