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,32 @@
|
||||
{% extends 'base/layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}{% blocktrans with name=object.name %}Clone {{ name }}{% endblocktrans %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
<div class="col col-md-8 offset-md-2">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% blocktrans with name=object.name %}Clone concept {{ name }}{% endblocktrans %}</h2>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
{% blocktrans count counter=object.devices.count %}
|
||||
The original keeps its {{ counter }} planned device. The clone is independent from this point on.
|
||||
{% plural %}
|
||||
The original keeps its {{ counter }} planned devices. The clone is independent from this point on.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% render_form form %}
|
||||
</div>
|
||||
<div class="card-footer text-end">
|
||||
<a href="{{ return_url }}" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
|
||||
<button type="submit" class="btn btn-primary">{% trans "Create clone" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,48 @@
|
||||
{% extends 'base/layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}{% blocktrans with name=object.name %}Deploy {{ name }}{% endblocktrans %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
<div class="col col-md-8 offset-md-2">
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% blocktrans with name=object.name %}Deploy concept {{ name }}{% endblocktrans %}</h2>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
{% trans "This creates real racks and devices in NetBox. The concept itself is left untouched and stays reusable." %}
|
||||
{% blocktrans count counter=deployable_count %}
|
||||
{{ counter }} planned device will be created per rack.
|
||||
{% plural %}
|
||||
{{ counter }} planned devices will be created per rack.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% render_form form %}
|
||||
</div>
|
||||
<div class="card-footer text-end">
|
||||
<a href="{{ return_url }}" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
|
||||
<button type="submit" class="btn btn-primary">{% trans "Deploy" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if warnings %}
|
||||
<div class="card">
|
||||
<h2 class="card-header text-warning">
|
||||
<i class="mdi mdi-alert"></i> {% trans "Will be skipped" %}
|
||||
</h2>
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for warning in warnings %}
|
||||
<li class="list-group-item">{{ warning }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,102 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load i18n %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Planned Device" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Concept" %}</th>
|
||||
<td>{{ object.concept|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Label" %}</th>
|
||||
<td>{{ object.label|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Status" %}</th>
|
||||
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Tenant" %}</th>
|
||||
<td>{{ object.tenant|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Copied from" %}</th>
|
||||
<td>{{ object.source_device|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
</div>
|
||||
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Hardware" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Manufacturer" %}</th>
|
||||
<td>{{ object.device_type.manufacturer|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Device type" %}</th>
|
||||
<td>{{ object.device_type|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Role" %}</th>
|
||||
<td>{{ object.role|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Platform" %}</th>
|
||||
<td>{{ object.platform|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Serial" %}</th>
|
||||
<td>{{ object.serial|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Placement" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Position" %}</th>
|
||||
<td>{% if object.position %}U{{ object.position }}{% else %}{% trans "Not racked" %}{% endif %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Height" %}</th>
|
||||
<td>{{ object.effective_u_height }}U</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Face" %}</th>
|
||||
<td>{{ object.get_face_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Width" %}</th>
|
||||
<td>{{ object.get_width_fraction_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Horizontal position" %}</th>
|
||||
<td>{{ object.horizontal_position }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Full depth" %}</th>
|
||||
<td>{% checkmark object.effective_full_depth %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,55 @@
|
||||
{% load i18n %}
|
||||
{% load l10n %}
|
||||
{% comment %}
|
||||
One face of a concept rack. Localization is switched off below because SVG
|
||||
coordinates must use a dot as the decimal separator whatever the UI language is.
|
||||
{% endcomment %}
|
||||
{% localize off %}
|
||||
<div class="concept-elevation" data-face="{{ elevation.face }}">
|
||||
<h5 class="text-center text-muted mb-2">{{ elevation.face_label|title }}</h5>
|
||||
<svg width="{{ elevation.width }}" height="{{ elevation.height }}"
|
||||
viewBox="0 0 {{ elevation.width }} {{ elevation.height }}"
|
||||
class="concept-elevation-svg" role="img"
|
||||
aria-label="{% blocktrans with face=elevation.face_label name=elevation.concept.name %}{{ face }} elevation of {{ name }}{% endblocktrans %}">
|
||||
|
||||
{# Unit rows and legend #}
|
||||
{% for unit in elevation.units %}
|
||||
<g class="unit">
|
||||
<rect x="{{ elevation.legend_width }}" y="{{ unit.y }}"
|
||||
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
|
||||
class="unit-slot"
|
||||
data-unit="{{ unit.number }}"></rect>
|
||||
<text x="{{ elevation.legend_width }}" y="{{ unit.y }}" dx="-4" dy="15"
|
||||
class="unit-label" text-anchor="end">{{ unit.number }}</text>
|
||||
</g>
|
||||
{% endfor %}
|
||||
|
||||
{# Devices #}
|
||||
{% for block in elevation.blocks %}
|
||||
<g class="device-block {% if block.opposite %}opposite{% endif %}"
|
||||
data-device-id="{{ block.device.pk }}"
|
||||
data-position="{{ block.device.position }}"
|
||||
data-u-height="{{ block.device.effective_u_height }}"
|
||||
data-width-fraction="{{ block.device.width_fraction }}">
|
||||
<a href="{{ block.device.get_absolute_url }}">
|
||||
<rect x="{{ block.x }}" y="{{ block.y }}" width="{{ block.width }}" height="{{ block.height }}"
|
||||
rx="2" fill="#{{ block.color }}"
|
||||
fill-opacity="{% if block.opposite %}0.35{% else %}1{% endif %}"
|
||||
class="device-rect"></rect>
|
||||
{% if block.show_text %}
|
||||
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
|
||||
fill="#{{ block.text_color }}" class="device-label">
|
||||
{{ block.label }}{% if block.opposite %} <tspan class="opposite-hint">({% trans "rear" %})</tspan>{% endif %}
|
||||
</text>
|
||||
{% endif %}
|
||||
<title>{{ block.label }}{% if block.sublabel %} · {{ block.sublabel }}{% endif %} · U{{ block.device.position }}</title>
|
||||
</a>
|
||||
</g>
|
||||
{% endfor %}
|
||||
|
||||
<rect x="{{ elevation.legend_width }}" y="6"
|
||||
width="{{ elevation.rack_width }}" height="{{ elevation.height|add:'-12' }}"
|
||||
class="rack-frame" fill="none"></rect>
|
||||
</svg>
|
||||
</div>
|
||||
{% endlocalize %}
|
||||
@@ -0,0 +1,16 @@
|
||||
{% load i18n %}
|
||||
{% load helpers %}
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Rack Concepts" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
{% for concept in concepts %}
|
||||
<tr>
|
||||
<td>{{ concept|linkify }}</td>
|
||||
<td class="text-end">{% badge concept.get_status_display bg_color=concept.get_status_color %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<div class="card-footer small text-muted">
|
||||
{% trans "Concepts copied from this rack. They are planning objects and do not affect this rack." %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
{% load i18n %}
|
||||
<a href="{% url 'plugins:netbox_rack_concept:rack_copy' pk=rack.pk %}" class="btn btn-outline-secondary">
|
||||
<i class="mdi mdi-view-grid-plus-outline"></i> {% trans "Copy to concept" %}
|
||||
</a>
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends 'base/layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}{% blocktrans with rack=rack.name %}Copy {{ rack }} to a concept{% endblocktrans %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
<div class="col col-md-8 offset-md-2">
|
||||
<div class="card">
|
||||
<h2 class="card-header">
|
||||
{% blocktrans with rack=rack.name %}Copy rack {{ rack }} to a concept{% endblocktrans %}
|
||||
</h2>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
{% blocktrans count counter=device_count %}
|
||||
The rack contains {{ counter }} racked device, which can be snapshotted into the concept.
|
||||
{% plural %}
|
||||
The rack contains {{ counter }} racked devices, which can be snapshotted into the concept.
|
||||
{% endblocktrans %}
|
||||
{% trans "Nothing in the rack is modified: the concept is a separate object that does not appear in the rack or device lists." %}
|
||||
</p>
|
||||
{% render_form form %}
|
||||
</div>
|
||||
<div class="card-footer text-end">
|
||||
<a href="{{ return_url }}" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
|
||||
<button type="submit" class="btn btn-primary">{% trans "Create concept" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,168 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load i18n %}
|
||||
{% load helpers %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load static %}
|
||||
|
||||
{% block head %}
|
||||
{{ block.super }}
|
||||
<link rel="stylesheet" href="{% static 'netbox_rack_concept/elevation.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_controls %}
|
||||
{{ block.super }}
|
||||
{% if perms.netbox_rack_concept.add_rackconcept %}
|
||||
<a href="{% url 'plugins:netbox_rack_concept:rackconcept_clone_concept' pk=object.pk %}" class="btn btn-outline-secondary">
|
||||
<i class="mdi mdi-content-duplicate"></i> {% trans "Clone concept" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_rack %}
|
||||
<a href="{% url 'plugins:netbox_rack_concept:rackconcept_deploy' pk=object.pk %}" class="btn btn-primary">
|
||||
<i class="mdi mdi-rocket-launch-outline"></i> {% trans "Deploy to NetBox" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Rack Concept" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Status" %}</th>
|
||||
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Tenant group" %}</th>
|
||||
<td>{{ object.tenant_group|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Tenant" %}</th>
|
||||
<td>{{ object.tenant|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Copied from" %}</th>
|
||||
<td>{{ object.source_rack|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Planned devices" %}</th>
|
||||
<td>{{ object.devices.count }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Space utilization" %}</th>
|
||||
<td>{% utilization_graph object.utilization %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Geometry" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Rack type" %}</th>
|
||||
<td>{{ object.rack_type|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Form factor" %}</th>
|
||||
<td>{{ object.get_form_factor_display|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Width" %}</th>
|
||||
<td>{{ object.get_width_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Height" %}</th>
|
||||
<td>{{ object.u_height }}U</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Starting unit" %}</th>
|
||||
<td>U{{ object.starting_unit }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Descending units" %}</th>
|
||||
<td>{% checkmark object.desc_units %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Airflow" %}</th>
|
||||
<td>{{ object.get_airflow_display|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Mounting depth" %}</th>
|
||||
<td>{{ object.mounting_depth|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Deployment defaults" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<td>{{ object.default_site|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Location" %}</th>
|
||||
<td>{{ object.default_location|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Rack role" %}</th>
|
||||
<td>{{ object.rack_role|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
</div>
|
||||
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">
|
||||
{% trans "Elevation" %}
|
||||
{% if perms.netbox_rack_concept.change_conceptdevice %}
|
||||
<span class="badge text-bg-secondary float-end">{% trans "Drag devices to move them" %}</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-around flex-wrap"
|
||||
id="concept-elevations"
|
||||
data-reorder-url="{% url 'plugins:netbox_rack_concept:rackconcept_reorder' pk=object.pk %}"
|
||||
data-editable="{% if perms.netbox_rack_concept.change_conceptdevice %}true{% else %}false{% endif %}"
|
||||
data-starting-unit="{{ object.starting_unit }}"
|
||||
data-u-height="{{ object.u_height }}"
|
||||
data-desc-units="{{ object.desc_units|yesno:'true,false' }}">
|
||||
{% include 'netbox_rack_concept/inc/elevation.html' with elevation=front_elevation %}
|
||||
{% include 'netbox_rack_concept/inc/elevation.html' with elevation=rear_elevation %}
|
||||
</div>
|
||||
<div id="concept-elevation-status" class="small text-muted mt-2"></div>
|
||||
</div>
|
||||
{% if perms.netbox_rack_concept.add_conceptdevice %}
|
||||
<div class="card-footer text-end">
|
||||
<a href="{% url 'plugins:netbox_rack_concept:conceptdevice_add' %}?concept={{ object.pk }}&return_url={{ object.get_absolute_url }}"
|
||||
class="btn btn-sm btn-primary">
|
||||
<i class="mdi mdi-plus-thick"></i> {% trans "Add planned device" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Deployments" %}</h2>
|
||||
{% render_table deployments_table %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascript %}
|
||||
{{ block.super }}
|
||||
<script src="{% static 'netbox_rack_concept/elevation.js' %}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends 'generic/object_children.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block bulk_extra_controls %}
|
||||
{{ block.super }}
|
||||
{% if perms.netbox_rack_concept.add_conceptdevice %}
|
||||
<a href="{% url 'plugins:netbox_rack_concept:conceptdevice_add' %}?concept={{ object.pk }}&return_url={{ request.path }}"
|
||||
class="btn btn-primary">
|
||||
<i class="mdi mdi-plus-thick"></i> {% trans "Add planned device" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user