Browse Source

feat(virtualization): Refactor VirtualMachine view to UI layout

Migrate the VirtualMachine detail view to SimpleLayout with standardized
panels for attributes, clusters, and resources. Modularize templates
to improve maintainability and reuse.

Fixes #21337
Martin Hauser 1 tuần trước cách đây
mục cha
commit
5013297326

+ 1 - 1
netbox/dcim/views.py

@@ -13,7 +13,6 @@ from django.utils.translation import gettext_lazy as _
 from django.views.generic import View
 
 from circuits.models import Circuit, CircuitTermination
-from dcim.ui import panels
 from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
 from extras.views import ObjectConfigContextView, ObjectRenderConfigView
 from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
@@ -44,6 +43,7 @@ from .choices import DeviceFaceChoices, InterfaceModeChoices
 from .models import *
 from .models.device_components import PortMapping
 from .object_actions import BulkAddComponents, BulkDisconnect
+from .ui import panels
 
 CABLE_TERMINATION_TYPES = {
     'dcim.consoleport': ConsolePort,

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

@@ -0,0 +1,34 @@
+{% load helpers %}
+{% load i18n %}
+
+<div class="card">
+  <h2 class="card-header">{% trans "Resources" %}</h2>
+  <table class="table table-hover attr-table">
+    <tr>
+      <th scope="row"><i class="mdi mdi-gauge"></i> {% trans "Virtual CPUs" %}</th>
+      <td>{{ object.vcpus|placeholder }}</td>
+    </tr>
+    <tr>
+      <th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
+      <td>
+        {% if object.memory %}
+          <span title={{ object.memory }}>{{ object.memory|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 object.disk %}
+          {{ object.disk|humanize_disk_megabytes }}
+        {% else %}
+          {{ ''|placeholder }}
+        {% endif %}
+      </td>
+    </tr>
+  </table>
+</div>

+ 0 - 198
netbox/templates/virtualization/virtualmachine.html

@@ -1,199 +1 @@
 {% extends 'virtualization/virtualmachine/base.html' %}
-{% load buttons %}
-{% load static %}
-{% load helpers %}
-{% load plugins %}
-{% load i18n %}
-
-{% block content %}
-<div class="row my-3">
-	<div class="col col-12 col-md-6">
-        <div class="card">
-            <h2 class="card-header">{% trans "Virtual Machine" %}</h2>
-            <table class="table table-hover attr-table">
-                <tr>
-                    <th scope="row">{% trans "Name" %}</th>
-                    <td>{{ object }}</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 "Start on boot" %}</th>
-                    <td>{% badge object.get_start_on_boot_display bg_color=object.get_start_on_boot_color %}</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 "Description" %}</th>
-                  <td>{{ object.description|placeholder }}</td>
-                </tr>
-                <tr>
-                  <th scope="row">{% trans "Serial Number" %}</th>
-                  <td>{{ object.serial|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 "Config Template" %}</th>
-                    <td>{{ object.config_template|linkify|placeholder }}</td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Primary IPv4" %}</th>
-                    <td>
-                      {% if object.primary_ip4 %}
-                        <a href="{% url 'ipam:ipaddress' pk=object.primary_ip4.pk %}" id="primary_ip4">{{ object.primary_ip4.address.ip }}</a>
-                        {% if object.primary_ip4.nat_inside %}
-                          ({% trans "NAT for" %} <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
-                        {% elif object.primary_ip4.nat_outside.exists %}
-                          ({% trans "NAT" %}: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
-                        {% endif %}
-                        {% copy_content "primary_ip4" %}
-                      {% else %}
-                        {{ ''|placeholder }}
-                      {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Primary IPv6" %}</th>
-                    <td>
-                      {% if object.primary_ip6 %}
-                        <a href="{% url 'ipam:ipaddress' pk=object.primary_ip6.pk %}" id="primary_ip6">{{ object.primary_ip6.address.ip }}</a>
-                        {% if object.primary_ip6.nat_inside %}
-                          ({% trans "NAT for" %} <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
-                        {% elif object.primary_ip6.nat_outside.exists %}
-                          ({% trans "NAT" %}: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
-                        {% endif %}
-                        {% copy_content "primary_ip6" %}
-                      {% else %}
-                        {{ ''|placeholder }}
-                      {% endif %}
-                    </td>
-                </tr>
-            </table>
-        </div>
-        {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' %}
-        {% 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 "Cluster" %}</h2>
-            <table class="table table-hover attr-table">
-                <tr>
-                    <th scope="row">{% trans "Site" %}</th>
-                    <td>
-                        {{ object.site|linkify|placeholder }}
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Cluster" %}</th>
-                    <td>
-                        {% if object.cluster.group %}
-                            {{ object.cluster.group|linkify }} /
-                        {% endif %}
-                        {{ object.cluster|linkify|placeholder }}
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Cluster Type" %}</th>
-                    <td>
-                        {{ object.cluster.type|linkify|placeholder }}
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Device" %}</th>
-                    <td>
-                        {{ object.device|linkify|placeholder }}
-                    </td>
-                </tr>
-            </table>
-        </div>
-        <div class="card">
-            <h2 class="card-header">{% trans "Resources" %}</h2>
-            <table class="table table-hover attr-table">
-                <tr>
-                    <th scope="row"><i class="mdi mdi-gauge"></i> {% trans "Virtual CPUs" %}</th>
-                    <td>{{ object.vcpus|placeholder }}</td>
-                </tr>
-                <tr>
-                    <th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
-                    <td>
-                        {% if object.memory %}
-                            <span title={{ object.memory }}>{{ object.memory|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 object.disk %}
-                      {{ object.disk|humanize_disk_megabytes }}
-                    {% else %}
-                      {{ ''|placeholder }}
-                    {% endif %}
-                  </td>
-                </tr>
-            </table>
-        </div>
-        <div class="card">
-          <h2 class="card-header">
-            {% trans "Application Services" %}
-            {% if perms.ipam.add_service %}
-              <div class="card-actions">
-                <a href="{% url 'ipam:service_add' %}?parent_object_type={{ object|content_type_id }}&parent={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
-                  <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add an application service" %}
-                </a>
-              </div>
-            {% endif %}
-          </h2>
-          {% htmx_table 'ipam:service_list' virtual_machine_id=object.pk %}
-        </div>
-        {% include 'inc/panels/image_attachments.html' %}
-        {% plugin_right_page object %}
-    </div>
-</div>
-
-<div class="row">
-  <div class="col col-md-12">
-    <div class="card">
-      <h2 class="card-header">
-        {% trans "Virtual Disks" %}
-        {% if perms.virtualization.add_virtualdisk %}
-          <div class="card-actions">
-            <a href="{% url 'virtualization:virtualdisk_add' %}?device={{ object.device.pk }}&virtual_machine={{ 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 Virtual Disk" %}
-            </a>
-          </div>
-        {% endif %}
-      </h2>
-      {% htmx_table 'virtualization:virtualdisk_list' virtual_machine_id=object.pk %}
-    </div>
-  </div>
-</div>
-
-<div class="row">
-    <div class="col col-md-12">
-        {% plugin_full_width_page object %}
-    </div>
-</div>
-{% endblock %}

+ 10 - 0
netbox/templates/virtualization/virtualmachine/attrs/ipaddress.html

@@ -0,0 +1,10 @@
+{% load i18n %}
+<a href="{{ value.get_absolute_url }}"{% if name %} id="attr_{{ name }}"{% endif %}>{{ value.address.ip }}</a>
+{% if value.nat_inside %}
+  ({% trans "NAT for" %} <a href="{{ value.nat_inside.get_absolute_url }}">{{ value.nat_inside.address.ip }}</a>)
+{% elif value.nat_outside.exists %}
+  ({% trans "NAT" %}: {% for nat in value.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
+{% endif %}
+<a class="btn btn-sm btn-primary copy-content" data-clipboard-target="#attr_{{ name }}" title="{% trans "Copy to clipboard" %}">
+  <i class="mdi mdi-content-copy"></i>
+</a>

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


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

@@ -0,0 +1,34 @@
+from django.utils.translation import gettext_lazy as _
+
+from netbox.ui import attrs, panels
+
+
+class VirtualMachinePanel(panels.ObjectAttributesPanel):
+    name = attrs.TextAttr('name')
+    status = attrs.ChoiceAttr('status')
+    start_on_boot = attrs.ChoiceAttr('start_on_boot')
+    role = attrs.RelatedObjectAttr('role', linkify=True)
+    platform = attrs.NestedObjectAttr('platform', linkify=True, max_depth=3)
+    description = attrs.TextAttr('description')
+    serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
+    primary_ip4 = attrs.TemplatedAttr(
+        'primary_ip4',
+        label=_('Primary IPv4'),
+        template_name='virtualization/virtualmachine/attrs/ipaddress.html',
+    )
+    primary_ip6 = attrs.TemplatedAttr(
+        'primary_ip6',
+        label=_('Primary IPv6'),
+        template_name='virtualization/virtualmachine/attrs/ipaddress.html',
+    )
+
+
+class VirtualMachineClusterPanel(panels.ObjectAttributesPanel):
+    title = _('Cluster')
+
+    site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group')
+    cluster = attrs.RelatedObjectAttr('cluster', linkify=True)
+    cluster_type = attrs.RelatedObjectAttr('cluster.type', linkify=True)
+    device = attrs.RelatedObjectAttr('device', linkify=True)

+ 43 - 0
netbox/virtualization/views.py

@@ -10,12 +10,15 @@ from dcim.filtersets import DeviceFilterSet
 from dcim.forms import DeviceFilterForm
 from dcim.models import Device
 from dcim.tables import DeviceTable
+from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
 from extras.views import ObjectConfigContextView, ObjectRenderConfigView
 from ipam.models import IPAddress, VLANGroup
 from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
 from netbox.object_actions import (
     AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename, DeleteObject, EditObject,
 )
+from netbox.ui import actions, layout
+from netbox.ui.panels import CommentsPanel, ObjectsTablePanel, TemplatePanel
 from netbox.views import generic
 from utilities.query import count_related
 from utilities.query_functions import CollateAsChar
@@ -23,6 +26,7 @@ from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
 from . import filtersets, forms, tables
 from .models import *
 from .object_actions import BulkAddComponents
+from .ui import panels
 
 
 #
@@ -336,6 +340,7 @@ class ClusterAddDevicesView(generic.ObjectEditView):
 # Virtual machines
 #
 
+
 @register_model_view(VirtualMachine, 'list', path='', detail=False)
 class VirtualMachineListView(generic.ObjectListView):
     queryset = VirtualMachine.objects.prefetch_related('primary_ip4', 'primary_ip6')
@@ -348,6 +353,44 @@ class VirtualMachineListView(generic.ObjectListView):
 @register_model_view(VirtualMachine)
 class VirtualMachineView(generic.ObjectView):
     queryset = VirtualMachine.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.VirtualMachinePanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+            CommentsPanel(),
+        ],
+        right_panels=[
+            panels.VirtualMachineClusterPanel(),
+            TemplatePanel('virtualization/panels/virtual_machine_resources.html'),
+            ObjectsTablePanel(
+                model='ipam.Service',
+                title=_('Application Services'),
+                filters={'virtual_machine_id': lambda ctx: ctx['object'].pk},
+                actions=[
+                    actions.AddObject(
+                        'ipam.Service',
+                        url_params={
+                            'parent_object_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
+                            'parent': lambda ctx: ctx['object'].pk,
+                        },
+                    ),
+                ],
+            ),
+            ImageAttachmentsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                model='virtualization.VirtualDisk',
+                filters={'virtual_machine_id': lambda ctx: ctx['object'].pk},
+                actions=[
+                    actions.AddObject(
+                        'virtualization.VirtualDisk', url_params={'virtual_machine': lambda ctx: ctx['object'].pk}
+                    ),
+                ],
+            ),
+        ],
+    )
 
 
 @register_model_view(VirtualMachine, 'interfaces')