Browse Source

Merge pull request #21496 from netbox-community/20923-convert-virtualization-views-to-new-ui-layout

Closes #20923: Migrate Virtualization object views to declarative layouts
bctiemann 21 hours ago
parent
commit
ae6f1f9ae3

+ 0 - 0
netbox/ipam/ui/__init__.py


+ 37 - 0
netbox/ipam/ui/panels.py

@@ -0,0 +1,37 @@
+from django.contrib.contenttypes.models import ContentType
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+
+from netbox.ui import actions, panels
+
+
+class FHRPGroupAssignmentsPanel(panels.ObjectPanel):
+    """
+    A panel which lists all FHRP group assignments for a given object.
+    """
+
+    template_name = 'ipam/panels/fhrp_groups.html'
+    title = _('FHRP Groups')
+    actions = [
+        actions.AddObject(
+            'ipam.FHRPGroup',
+            url_params={
+                'return_url': lambda ctx: reverse(
+                    'ipam:fhrpgroupassignment_add',
+                    query={
+                        'interface_type': ContentType.objects.get_for_model(ctx['object']).pk,
+                        'interface_id': ctx['object'].pk,
+                    },
+                ),
+            },
+            label=_('Create Group'),
+        ),
+        actions.AddObject(
+            'ipam.FHRPGroupAssignment',
+            url_params={
+                'interface_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
+                'interface_id': lambda ctx: ctx['object'].pk,
+            },
+            label=_('Assign Group'),
+        ),
+    ]

+ 27 - 0
netbox/netbox/ui/attrs.py

@@ -10,6 +10,7 @@ __all__ = (
     'BooleanAttr',
     'BooleanAttr',
     'ColorAttr',
     'ColorAttr',
     'ChoiceAttr',
     'ChoiceAttr',
+    'GenericForeignKeyAttr',
     'GPSCoordinatesAttr',
     'GPSCoordinatesAttr',
     'ImageAttr',
     'ImageAttr',
     'NestedObjectAttr',
     'NestedObjectAttr',
@@ -279,6 +280,32 @@ class NestedObjectAttr(ObjectAttribute):
         }
         }
 
 
 
 
+class GenericForeignKeyAttr(ObjectAttribute):
+    """
+    An attribute representing a related generic relation object.
+
+    This attribute is similar to `RelatedObjectAttr` but uses the
+    ContentType of the related object to be displayed alongside the value.
+
+    Parameters:
+         linkify (bool): If True, the rendered value will be hyperlinked
+             to the related object's detail view
+    """
+    template_name = 'ui/attrs/generic_object.html'
+
+    def __init__(self, *args, linkify=None, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.linkify = linkify
+
+    def get_context(self, obj, context):
+        value = self.get_value(obj)
+        content_type = value._meta.verbose_name
+        return {
+            'content_type': content_type,
+            'linkify': self.linkify,
+        }
+
+
 class AddressAttr(ObjectAttribute):
 class AddressAttr(ObjectAttribute):
     """
     """
     A physical or mailing address.
     A physical or mailing address.

+ 40 - 0
netbox/netbox/ui/panels.py

@@ -12,6 +12,7 @@ from utilities.views import get_viewname
 
 
 __all__ = (
 __all__ = (
     'CommentsPanel',
     'CommentsPanel',
+    'ContextTablePanel',
     'JSONPanel',
     'JSONPanel',
     'NestedGroupObjectPanel',
     'NestedGroupObjectPanel',
     'ObjectAttributesPanel',
     'ObjectAttributesPanel',
@@ -339,3 +340,42 @@ class PluginContentPanel(Panel):
     def render(self, context):
     def render(self, context):
         obj = context.get('object')
         obj = context.get('object')
         return _get_registered_content(obj, self.method, context)
         return _get_registered_content(obj, self.method, context)
+
+
+class ContextTablePanel(ObjectPanel):
+    """
+    A panel which renders a django-tables2/NetBoxTable instance provided
+    via the view's extra context.
+
+    This is useful when you already have a fully constructed table
+    (custom queryset, special columns, no list view) and just want to
+    render it inside a declarative layout panel.
+
+    Parameters:
+        table (str | callable): Either the context key holding the table
+            (e.g. "vlan_table") or a callable which accepts the template
+            context and returns a table instance.
+    """
+    template_name = 'ui/panels/context_table.html'
+
+    def __init__(self, table, **kwargs):
+        super().__init__(**kwargs)
+        self.table = table
+
+    def _resolve_table(self, context):
+        if callable(self.table):
+            return self.table(context)
+        return context.get(self.table)
+
+    def get_context(self, context):
+        table = self._resolve_table(context)
+        return {
+            **super().get_context(context),
+            'table': table,
+        }
+
+    def render(self, context):
+        table = self._resolve_table(context)
+        if table is None:
+            return ''
+        return super().render(context)

+ 47 - 0
netbox/templates/ipam/panels/fhrp_groups.html

@@ -0,0 +1,47 @@
+{% extends "ui/panels/_base.html" %}
+{% load perms %}
+{% load i18n %}
+
+{% block panel_content %}
+  <table class="table table-hover attr-table">
+    <thead>
+      <tr class="border-bottom">
+        <th>{% trans "Group" %}</th>
+        <th>{% trans "Protocol" %}</th>
+        <th>{% trans "Virtual IPs" %}</th>
+        <th>{% trans "Priority" %}</th>
+        <th></th>
+      </tr>
+    </thead>
+    <tbody>
+      {% for assignment in object.fhrp_group_assignments.all %}
+        <tr>
+          <td>{{ assignment.group|linkify:"group_id" }}</td>
+          <td>{{ assignment.group.get_protocol_display }}</td>
+          <td>
+            {% for ipaddress in assignment.group.ip_addresses.all %}
+              {{ ipaddress|linkify }}{% if not forloop.last %}<br />{% endif %}
+            {% endfor %}
+          </td>
+          <td>{{ assignment.priority }}</td>
+          <td class="text-end d-print-none">
+            {% if request.user|can_change:assignment %}
+              <a href="{% url 'ipam:fhrpgroupassignment_edit' pk=assignment.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-warning" title="{% trans "Edit" %}">
+                <i class="mdi mdi-pencil" aria-hidden="true"></i>
+              </a>
+            {% endif %}
+            {% if request.user|can_delete:assignment %}
+              <a href="{% url 'ipam:fhrpgroupassignment_delete' pk=assignment.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-danger" title="{% trans "Delete" %}">
+                <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
+              </a>
+            {% endif %}
+          </td>
+        </tr>
+      {% empty %}
+        <tr>
+          <td colspan="5" class="text-muted">{% trans "None" %}</td>
+        </tr>
+      {% endfor %}
+    </tbody>
+  </table>
+{% endblock panel_content %}

+ 3 - 0
netbox/templates/ui/attrs/generic_object.html

@@ -0,0 +1,3 @@
+<span{% if name %} id="attr_{{ name }}"{% endif %}>
+  {% if linkify %}{{ value|linkify }}{% else %}{{ value }}{% endif %}{% if content_type %} ({{ content_type }}){% endif %}
+</span>

+ 6 - 0
netbox/templates/ui/panels/context_table.html

@@ -0,0 +1,6 @@
+{% extends "ui/panels/_base.html" %}
+{% load render_table from django_tables2 %}
+
+{% block panel_content %}
+  {% render_table table 'inc/table.html' %}
+{% endblock panel_content %}

+ 0 - 94
netbox/templates/virtualization/cluster.html

@@ -1,95 +1 @@
 {% extends 'virtualization/cluster/base.html' %}
 {% extends 'virtualization/cluster/base.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load i18n %}
-
-{% block content %}
-<div class="row">
-  <div class="col col-12 col-md-6">
-    <div class="card">
-      <h2 class="card-header">{% trans "Cluster" %}</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 "Type" %}</th>
-          <td>{{ object.type|linkify }}</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 "Group" %}</th>
-          <td>{{ object.group|linkify|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Tenant" %}</th>
-          <td>
-            {% if object.tenant.group %}
-              {{ object.tenant.group|linkify }} /
-            {% endif %}
-            {{ object.tenant|linkify|placeholder }}
-            </td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Scope" %}</th>
-          {% if object.scope %}
-            <td>{{ object.scope|linkify }} ({% trans object.scope_type.name %})</td>
-          {% else %}
-            <td>{{ ''|placeholder }}</td>
-          {% endif %}
-        </tr>
-      </table>
-    </div>
-    {% include 'inc/panels/comments.html' %}
-    {% plugin_left_page object %}
-  </div>
-  <div class="col col-12 col-md-6">
-    <div class="card">
-      <h2 class="card-header">{% trans "Allocated Resources" %}</h2>
-      <table class="table table-hover attr-table">
-          <tr>
-              <th scope="row"><i class="mdi mdi-gauge"></i> {% trans "Virtual CPUs" %}</th>
-              <td>{{ vcpus_sum|placeholder }}</td>
-          </tr>
-          <tr>
-              <th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
-              <td>
-                  {% if memory_sum %}
-                      <span title={{ memory_sum }}>{{ memory_sum|humanize_ram_megabytes }}</span>
-                  {% else %}
-                      {{ ''|placeholder }}
-                  {% endif %}
-              </td>
-          </tr>
-          <tr>
-              <th scope="row"><i class="mdi mdi-harddisk"></i> {% trans "Disk Space" %}</th>
-              <td>
-                  {% if disk_sum %}
-                      {{ disk_sum|humanize_disk_megabytes }}
-                  {% else %}
-                      {{ ''|placeholder }}
-                  {% endif %}
-              </td>
-          </tr>
-      </table>
-  </div>
-    {% include 'inc/panels/related_objects.html' %}
-    {% include 'inc/panels/custom_fields.html' %}
-    {% include 'inc/panels/tags.html' %}
-    {% plugin_right_page object %}
-  </div>
-</div>
-<div class="row">
-  <div class="col col-md-12">
-    {% plugin_full_width_page object %}
-  </div>
-</div>
-{% endblock %}

+ 0 - 36
netbox/templates/virtualization/clustergroup.html

@@ -1,7 +1,4 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
 {% load i18n %}
 {% load i18n %}
 
 
 {% block extra_controls %}
 {% block extra_controls %}
@@ -11,36 +8,3 @@
     </a>
     </a>
   {% endif %}
   {% endif %}
 {% endblock extra_controls %}
 {% endblock extra_controls %}
-
-{% block content %}
-<div class="row mb-3">
-	<div class="col col-12 col-md-6">
-    <div class="card">
-      <h2 class="card-header">{% trans "Cluster Group" %}</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 "Description" %}</th>
-          <td>{{ object.description|placeholder }}</td>
-        </tr>
-      </table>
-    </div>
-    {% include 'inc/panels/tags.html' %}
-    {% plugin_left_page object %}
-	</div>
-	<div class="col col-12 col-md-6">
-    {% include 'inc/panels/related_objects.html' %}
-    {% include 'inc/panels/comments.html' %}
-    {% include 'inc/panels/custom_fields.html' %}
-    {% plugin_right_page object %}
-  </div>
-</div>
-<div class="row">
-	<div class="col col-md-12">
-    {% plugin_full_width_page object %}
-  </div>
-</div>
-{% endblock %}

+ 0 - 42
netbox/templates/virtualization/clustertype.html

@@ -1,7 +1,4 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
 {% load i18n %}
 {% load i18n %}
 
 
 {% block extra_controls %}
 {% block extra_controls %}
@@ -11,42 +8,3 @@
     </a>
     </a>
   {% endif %}
   {% endif %}
 {% endblock extra_controls %}
 {% endblock extra_controls %}
-
-{% block content %}
-<div class="row mb-3">
-	<div class="col col-12 col-md-6">
-    <div class="card">
-      <h2 class="card-header">{% trans "Cluster Type" %}</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 "Description" %}</th>
-          <td>{{ object.description|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Clusters" %}</th>
-          <td>
-            <a href="{% url 'virtualization:cluster_list' %}?type_id={{ object.pk }}">{{ object.clusters.count }}</a>
-          </td>
-        </tr>
-      </table>
-    </div>
-    {% include 'inc/panels/tags.html' %}
-    {% plugin_left_page object %}
-	</div>
-	<div class="col col-12 col-md-6">
-    {% include 'inc/panels/related_objects.html' %}
-    {% include 'inc/panels/comments.html' %}
-    {% include 'inc/panels/custom_fields.html' %}
-    {% plugin_right_page object %}
-  </div>
-</div>
-<div class="row">
-	<div class="col col-md-12">
-    {% plugin_full_width_page object %}
-  </div>
-</div>
-{% endblock %}

+ 34 - 0
netbox/templates/virtualization/panels/cluster_resources.html

@@ -0,0 +1,34 @@
+{% load helpers %}
+{% load i18n %}
+
+<div class="card">
+  <h2 class="card-header">{% trans "Allocated Resources" %}</h2>
+  <table class="table table-hover attr-table">
+    <tr>
+      <th scope="row"><i class="mdi mdi-gauge"></i> {% trans "Virtual CPUs" %}</th>
+      <td>{{ vcpus_sum|placeholder }}</td>
+    </tr>
+    <tr>
+      <th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
+      <td>
+        {% if memory_sum %}
+          <span title={{ memory_sum }}>{{ memory_sum|humanize_ram_megabytes }}</span>
+        {% else %}
+          {{ ''|placeholder }}
+        {% endif %}
+      </td>
+    </tr>
+    <tr>
+      <th scope="row">
+        <i class="mdi mdi-harddisk"></i> {% trans "Disk Space" %}
+      </th>
+      <td>
+        {% if disk_sum %}
+          {{ disk_sum|humanize_disk_megabytes }}
+        {% else %}
+          {{ ''|placeholder }}
+        {% endif %}
+      </td>
+    </tr>
+  </table>
+</div>

+ 0 - 45
netbox/templates/virtualization/virtualdisk.html

@@ -10,48 +10,3 @@
      <a href="{% url 'virtualization:virtualmachine_disks' pk=object.virtual_machine.pk %}">{{ object.virtual_machine }}</a>
      <a href="{% url 'virtualization:virtualmachine_disks' pk=object.virtual_machine.pk %}">{{ object.virtual_machine }}</a>
   </li>
   </li>
 {% endblock %}
 {% endblock %}
-
-{% block content %}
-  <div class="row mb-3">
-    <div class="col col-12 col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Virtual Disk" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Virtual Machine" %}</th>
-            <td>{{ object.virtual_machine|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Name" %}</th>
-            <td>{{ object.name }}</td>
-          </tr>
-          <tr>
-            <th scope="row"><i class="mdi mdi-harddisk"></i> {% trans "Size" %}</th>
-            <td>
-              {% if object.size %}
-                {{ object.size|humanize_disk_megabytes }}
-              {% else %}
-                {{ ''|placeholder }}
-              {% endif %}
-            </td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Description" %}</th>
-            <td>{{ object.description|placeholder }}</td>
-          </tr>
-        </table>
-      </div>
-      {% include 'inc/panels/tags.html' %}
-      {% plugin_left_page object %}
-    </div>
-    <div class="col col-12 col-md-6">
-      {% include 'inc/panels/custom_fields.html' %}
-      {% plugin_right_page object %}
-    </div>
-  </div>
-  <div class="row">
-    <div class="col col-md-12">
-      {% plugin_full_width_page object %}
-    </div>
-  </div>
-{% endblock %}

+ 2 - 0
netbox/templates/virtualization/virtualdisk/attrs/size.html

@@ -0,0 +1,2 @@
+{% load helpers %}
+{{ value|humanize_disk_megabytes }}

+ 0 - 152
netbox/templates/virtualization/vminterface.html

@@ -1,8 +1,4 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
-{% load i18n %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
   {{ block.super }}
   {{ block.super }}
@@ -10,151 +6,3 @@
     <a href="{% url 'virtualization:virtualmachine_interfaces' pk=object.virtual_machine.pk %}">{{ object.virtual_machine }}</a>
     <a href="{% url 'virtualization:virtualmachine_interfaces' pk=object.virtual_machine.pk %}">{{ object.virtual_machine }}</a>
   </li>
   </li>
 {% endblock %}
 {% endblock %}
-
-{% block content %}
-<div class="row mb-3">
-	<div class="col col-12 col-md-6">
-    <div class="card">
-      <h2 class="card-header">{% trans "Interface" %}</h2>
-      <table class="table table-hover attr-table">
-        <tr>
-          <th scope="row">{% trans "Virtual Machine" %}</th>
-          <td>{{ object.virtual_machine|linkify }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Name" %}</th>
-          <td>{{ object.name }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Enabled" %}</th>
-          <td>
-            {% if object.enabled %}
-              <span class="text-success"><i class="mdi mdi-check-bold"></i></span>
-            {% else %}
-              <span class="text-danger"><i class="mdi mdi-close"></i></span>
-            {% endif %}
-          </td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Parent" %}</th>
-          <td>{{ object.parent|linkify|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Bridge" %}</th>
-          <td>{{ object.bridge|linkify|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Description" %}</th>
-          <td>{{ object.description|placeholder }} </td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "MTU" %}</th>
-          <td>{{ object.mtu|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "802.1Q Mode" %}</th>
-          <td>{{ object.get_mode_display|placeholder }}</td>
-        </tr>
-        {% if object.mode == 'q-in-q' %}
-          <tr>
-              <th scope="row">{% trans "Q-in-Q SVLAN" %}</th>
-              <td>{{ object.qinq_svlan|linkify|placeholder }}</td>
-          </tr>
-        {% endif %}
-        <tr>
-          <th scope="row">{% trans "Tunnel" %}</th>
-          <td>{{ object.tunnel_termination.tunnel|linkify|placeholder }}</td>
-        </tr>
-      </table>
-    </div>
-    {% include 'inc/panels/tags.html' %}
-    {% plugin_left_page object %}
-  </div>
-  <div class="col col-12 col-md-6">
-    {% include 'inc/panels/custom_fields.html' %}
-    <div class="card">
-      <h2 class="card-header">{% trans "Addressing" %}</h2>
-      <table class="table table-hover attr-table">
-        <tr>
-          <th scope="row">{% trans "MAC Address" %}</th>
-          <td>
-            {% if object.primary_mac_address %}
-              <span class="font-monospace">{{ object.primary_mac_address|linkify }}</span>
-              <span class="badge text-bg-primary">{% trans "Primary" %}</span>
-            {% else %}
-              {{ ''|placeholder }}
-            {% endif %}
-          </td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "VRF" %}</th>
-          <td>{{ object.vrf|linkify|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "VLAN Translation" %}</th>
-          <td>{{ object.vlan_translation_policy|linkify|placeholder }}</td>
-        </tr>
-      </table>
-    </div>
-    {% include 'ipam/inc/panels/fhrp_groups.html' %}
-    {% plugin_right_page object %}
-  </div>
-</div>
-<div class="row mb-3">
-    <div class="col col-md-12">
-        <div class="card">
-            <h2 class="card-header">
-              {% trans "IP Addresses" %}
-              {% if perms.ipam.add_ipaddress %}
-                <div class="card-actions">
-                  <a href="{% url 'ipam:ipaddress_add' %}?virtual_machine={{ object.virtual_machine.pk }}&vminterface={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
-                    <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add IP Address" %}
-                  </a>
-                </div>
-              {% endif %}
-            </h2>
-            {% htmx_table 'ipam:ipaddress_list' vminterface_id=object.pk %}
-        </div>
-    </div>
-</div>
-<div class="row mb-3">
-    <div class="col col-md-12">
-        <div class="card">
-            <h2 class="card-header">
-                {% trans "MAC Addresses" %}
-                {% if perms.ipam.add_macaddress %}
-                    <div class="card-actions">
-                        <a href="{% url 'dcim:macaddress_add' %}?virtual_machine={{ object.device.pk }}&vminterface={{ object.pk }}&return_url={{ object.get_absolute_url }}"
-                           class="btn btn-ghost-primary btn-sm">
-                            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add MAC Address" %}
-                        </a>
-                    </div>
-                {% endif %}
-            </h2>
-            {% htmx_table 'dcim:macaddress_list' vminterface_id=object.pk %}
-        </div>
-    </div>
-</div>
-<div class="row mb-3">
-    <div class="col col-md-12">
-        {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
-    </div>
-</div>
-{% if object.vlan_translation_policy %}
-    <div class="row mb-3">
-        <div class="col col-md-12">
-            {% include 'inc/panel_table.html' with table=vlan_translation_table heading="VLAN Translation" %}
-        </div>
-    </div>
-{% endif %}
-<div class="row mb-3">
-    <div class="col col-md-12">
-        {% include 'inc/panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}
-    </div>
-</div>
-<div class="row">
-    <div class="col col-md-12">
-        {% plugin_full_width_page object %}
-    </div>
-</div>
-{% endblock %}

+ 42 - 0
netbox/virtualization/ui/panels.py

@@ -3,6 +3,16 @@ from django.utils.translation import gettext_lazy as _
 from netbox.ui import attrs, panels
 from netbox.ui import attrs, panels
 
 
 
 
+class ClusterPanel(panels.ObjectAttributesPanel):
+    name = attrs.TextAttr('name')
+    type = attrs.RelatedObjectAttr('type', linkify=True)
+    status = attrs.ChoiceAttr('status')
+    description = attrs.TextAttr('description')
+    group = attrs.RelatedObjectAttr('group', linkify=True)
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    scope = attrs.GenericForeignKeyAttr('scope', linkify=True)
+
+
 class VirtualMachinePanel(panels.ObjectAttributesPanel):
 class VirtualMachinePanel(panels.ObjectAttributesPanel):
     name = attrs.TextAttr('name')
     name = attrs.TextAttr('name')
     status = attrs.ChoiceAttr('status')
     status = attrs.ChoiceAttr('status')
@@ -32,3 +42,35 @@ class VirtualMachineClusterPanel(panels.ObjectAttributesPanel):
     cluster = attrs.RelatedObjectAttr('cluster', linkify=True)
     cluster = attrs.RelatedObjectAttr('cluster', linkify=True)
     cluster_type = attrs.RelatedObjectAttr('cluster.type', linkify=True)
     cluster_type = attrs.RelatedObjectAttr('cluster.type', linkify=True)
     device = attrs.RelatedObjectAttr('device', linkify=True)
     device = attrs.RelatedObjectAttr('device', linkify=True)
+
+
+class VirtualDiskPanel(panels.ObjectAttributesPanel):
+    virtual_machine = attrs.RelatedObjectAttr('virtual_machine', linkify=True, label=_('Virtual Machine'))
+    name = attrs.TextAttr('name')
+    size = attrs.TemplatedAttr('size', template_name='virtualization/virtualdisk/attrs/size.html')
+    description = attrs.TextAttr('description')
+
+
+class VMInterfacePanel(panels.ObjectAttributesPanel):
+    virtual_machine = attrs.RelatedObjectAttr('virtual_machine', linkify=True, label=_('Virtual Machine'))
+    name = attrs.TextAttr('name')
+    enabled = attrs.BooleanAttr('enabled')
+    parent = attrs.RelatedObjectAttr('parent_interface', linkify=True)
+    bridge = attrs.RelatedObjectAttr('bridge', linkify=True)
+    description = attrs.TextAttr('description')
+    mtu = attrs.TextAttr('mtu', label=_('MTU'))
+    mode = attrs.ChoiceAttr('mode', label=_('802.1Q Mode'))
+    qinq_svlan = attrs.RelatedObjectAttr('qinq_svlan', linkify=True, label=_('Q-in-Q SVLAN'))
+    tunnel_termination = attrs.RelatedObjectAttr('tunnel_termination.tunnel', linkify=True, label=_('Tunnel'))
+
+
+class VMInterfaceAddressingPanel(panels.ObjectAttributesPanel):
+    title = _('Addressing')
+
+    primary_mac_address = attrs.TextAttr(
+        'primary_mac_address', label=_('MAC Address'), style='font-monospace', copy_button=True
+    )
+    vrf = attrs.RelatedObjectAttr('vrf', linkify=True, label=_('VRF'))
+    vlan_translation_policy = attrs.RelatedObjectAttr(
+        'vlan_translation_policy', linkify=True, label=_('VLAN Translation')
+    )

+ 91 - 1
netbox/virtualization/views.py

@@ -14,6 +14,7 @@ from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
 from extras.views import ObjectConfigContextView, ObjectRenderConfigView
 from extras.views import ObjectConfigContextView, ObjectRenderConfigView
 from ipam.models import IPAddress, VLANGroup
 from ipam.models import IPAddress, VLANGroup
 from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
 from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
+from ipam.ui.panels import FHRPGroupAssignmentsPanel
 from netbox.object_actions import (
 from netbox.object_actions import (
     AddObject,
     AddObject,
     BulkDelete,
     BulkDelete,
@@ -25,7 +26,14 @@ from netbox.object_actions import (
     EditObject,
     EditObject,
 )
 )
 from netbox.ui import actions, layout
 from netbox.ui import actions, layout
-from netbox.ui.panels import CommentsPanel, ObjectsTablePanel, TemplatePanel
+from netbox.ui.panels import (
+    CommentsPanel,
+    ContextTablePanel,
+    ObjectsTablePanel,
+    OrganizationalObjectPanel,
+    RelatedObjectsPanel,
+    TemplatePanel,
+)
 from netbox.views import generic
 from netbox.views import generic
 from utilities.query import count_related
 from utilities.query import count_related
 from utilities.query_functions import CollateAsChar
 from utilities.query_functions import CollateAsChar
@@ -54,6 +62,17 @@ class ClusterTypeListView(generic.ObjectListView):
 @register_model_view(ClusterType)
 @register_model_view(ClusterType)
 class ClusterTypeView(GetRelatedModelsMixin, generic.ObjectView):
 class ClusterTypeView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = ClusterType.objects.all()
     queryset = ClusterType.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            OrganizationalObjectPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CustomFieldsPanel(),
+            CommentsPanel(),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -121,6 +140,17 @@ class ClusterGroupListView(generic.ObjectListView):
 @register_model_view(ClusterGroup)
 @register_model_view(ClusterGroup)
 class ClusterGroupView(GetRelatedModelsMixin, generic.ObjectView):
 class ClusterGroupView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = ClusterGroup.objects.all()
     queryset = ClusterGroup.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            OrganizationalObjectPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CustomFieldsPanel(),
+            CommentsPanel(),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -202,6 +232,18 @@ class ClusterListView(generic.ObjectListView):
 @register_model_view(Cluster)
 @register_model_view(Cluster)
 class ClusterView(GetRelatedModelsMixin, generic.ObjectView):
 class ClusterView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Cluster.objects.all()
     queryset = Cluster.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.ClusterPanel(),
+            CommentsPanel(),
+        ],
+        right_panels=[
+            TemplatePanel('virtualization/panels/cluster_resources.html'),
+            RelatedObjectsPanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -507,6 +549,7 @@ class VirtualMachineBulkDeleteView(generic.BulkDeleteView):
 # VM interfaces
 # VM interfaces
 #
 #
 
 
+
 @register_model_view(VMInterface, 'list', path='', detail=False)
 @register_model_view(VMInterface, 'list', path='', detail=False)
 class VMInterfaceListView(generic.ObjectListView):
 class VMInterfaceListView(generic.ObjectListView):
     queryset = VMInterface.objects.all()
     queryset = VMInterface.objects.all()
@@ -518,6 +561,44 @@ class VMInterfaceListView(generic.ObjectListView):
 @register_model_view(VMInterface)
 @register_model_view(VMInterface)
 class VMInterfaceView(generic.ObjectView):
 class VMInterfaceView(generic.ObjectView):
     queryset = VMInterface.objects.all()
     queryset = VMInterface.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.VMInterfacePanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            CustomFieldsPanel(),
+            panels.VMInterfaceAddressingPanel(),
+            FHRPGroupAssignmentsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                model='ipam.IPaddress',
+                filters={'vminterface_id': lambda ctx: ctx['object'].pk},
+                actions=[
+                    actions.AddObject(
+                        'ipam.IPaddress',
+                        url_params={
+                            'virtual_machine': lambda ctx: ctx['object'].virtual_machine.pk,
+                            'vminterface': lambda ctx: ctx['object'].pk,
+                        },
+                    ),
+                ],
+            ),
+            ObjectsTablePanel(
+                model='dcim.MACAddress',
+                filters={'vminterface_id': lambda ctx: ctx['object'].pk},
+                actions=[
+                    actions.AddObject(
+                        'dcim.MACAddress', url_params={'vminterface': lambda ctx: ctx['object'].pk}
+                    ),
+                ],
+            ),
+            ContextTablePanel('vlan_table', title=_('Assigned VLANs')),
+            ContextTablePanel('vlan_translation_table', title=_('VLAN Translation')),
+            ContextTablePanel('child_interfaces_table', title=_('Child Interfaces')),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
 
 
@@ -623,6 +704,15 @@ class VirtualDiskListView(generic.ObjectListView):
 @register_model_view(VirtualDisk)
 @register_model_view(VirtualDisk)
 class VirtualDiskView(generic.ObjectView):
 class VirtualDiskView(generic.ObjectView):
     queryset = VirtualDisk.objects.all()
     queryset = VirtualDisk.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.VirtualDiskPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            CustomFieldsPanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(VirtualDisk, 'add', detail=False)
 @register_model_view(VirtualDisk, 'add', detail=False)