Kaynağa Gözat

Closes #21866: Include the PostgreSQL database schema within System details (#21901)

Expose the current PostgreSQL schema from the system view and include it
in the exported system data.

Load the Database tab on demand with HTMX so schema introspection only
runs when the panel is opened, while keeping the export path eager.
Use the active PostgreSQL schema instead of assuming `public`, move the
schema helpers into `core.utils`, and tidy the accordion toggle styling.
Arthur Hanson 1 ay önce
ebeveyn
işleme
900f1155af

+ 1 - 0
netbox/core/tests/test_views.py

@@ -373,6 +373,7 @@ class SystemTestCase(TestCase):
         self.assertIn('plugins', data)
         self.assertIn('plugins', data)
         self.assertIn('config', data)
         self.assertIn('config', data)
         self.assertIn('objects', data)
         self.assertIn('objects', data)
+        self.assertIn('db_schema', data)
 
 
     def test_system_view_with_config_revision(self):
     def test_system_view_with_config_revision(self):
         ConfigRevision.objects.create()
         ConfigRevision.objects.create()

+ 1 - 0
netbox/core/urls.py

@@ -50,6 +50,7 @@ urlpatterns = (
     path('config-revisions/<int:pk>/', include(get_model_urls('core', 'configrevision'))),
     path('config-revisions/<int:pk>/', include(get_model_urls('core', 'configrevision'))),
 
 
     path('system/', views.SystemView.as_view(), name='system'),
     path('system/', views.SystemView.as_view(), name='system'),
+    path('system/db-schema/', views.SystemDBSchemaView.as_view(), name='system_db_schema'),
 
 
     path('plugins/', views.PluginListView.as_view(), name='plugin_list'),
     path('plugins/', views.PluginListView.as_view(), name='plugin_list'),
     path('plugins/<str:name>/', views.PluginView.as_view(), name='plugin'),
     path('plugins/<str:name>/', views.PluginView.as_view(), name='plugin'),

+ 49 - 0
netbox/core/utils.py

@@ -1,3 +1,4 @@
+from django.db import DatabaseError, connection
 from django.http import Http404
 from django.http import Http404
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from django_rq.queues import get_queue, get_queue_by_index, get_redis_connection
 from django_rq.queues import get_queue, get_queue_by_index, get_redis_connection
@@ -18,6 +19,7 @@ from rq.registry import (
 __all__ = (
 __all__ = (
     'delete_rq_job',
     'delete_rq_job',
     'enqueue_rq_job',
     'enqueue_rq_job',
+    'get_db_schema',
     'get_rq_jobs',
     'get_rq_jobs',
     'get_rq_jobs_from_status',
     'get_rq_jobs_from_status',
     'requeue_rq_job',
     'requeue_rq_job',
@@ -154,3 +156,50 @@ def stop_rq_job(job_id):
     queue = get_queue_by_index(queue_index)
     queue = get_queue_by_index(queue_index)
 
 
     return stop_jobs(queue, job_id)[0]
     return stop_jobs(queue, job_id)[0]
+
+
+def get_db_schema():
+    """
+    Query the current PostgreSQL schema and return a list of tables, each with its columns and
+    indexes. Returns an empty list if the database is not accessible.
+    """
+    db_schema = []
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute("""
+                SELECT table_name, column_name, data_type, is_nullable, column_default
+                FROM information_schema.columns
+                WHERE table_schema = current_schema()
+                ORDER BY table_name, ordinal_position
+            """)
+            columns_by_table = {}
+            for table_name, column_name, data_type, is_nullable, column_default in cursor.fetchall():
+                columns_by_table.setdefault(table_name, []).append({
+                    'name': column_name,
+                    'type': data_type,
+                    'nullable': is_nullable == 'YES',
+                    'default': column_default,
+                })
+
+            cursor.execute("""
+                SELECT tablename, indexname, indexdef
+                FROM pg_indexes
+                WHERE schemaname = current_schema()
+                ORDER BY tablename, indexname
+            """)
+            indexes_by_table = {}
+            for table_name, index_name, index_def in cursor.fetchall():
+                indexes_by_table.setdefault(table_name, []).append({
+                    'name': index_name,
+                    'definition': index_def,
+                })
+
+        for table_name in sorted(columns_by_table.keys()):
+            db_schema.append({
+                'name': table_name,
+                'columns': columns_by_table[table_name],
+                'indexes': indexes_by_table.get(table_name, []),
+            })
+    except DatabaseError:
+        pass
+    return db_schema

+ 87 - 18
netbox/core/views.py

@@ -3,11 +3,12 @@ import platform
 from copy import deepcopy
 from copy import deepcopy
 
 
 from django import __version__ as django_version
 from django import __version__ as django_version
+from django.apps import apps as django_apps_registry
 from django.conf import settings
 from django.conf import settings
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.auth.mixins import UserPassesTestMixin
 from django.contrib.auth.mixins import UserPassesTestMixin
 from django.core.cache import cache
 from django.core.cache import cache
-from django.db import ProgrammingError, connection
+from django.db import DatabaseError, connection
 from django.http import Http404, HttpResponse, HttpResponseForbidden
 from django.http import Http404, HttpResponse, HttpResponseForbidden
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
@@ -22,10 +23,18 @@ from rq.job import JobStatus as RQJobStatus
 from rq.worker import Worker
 from rq.worker import Worker
 from rq.worker_registration import clean_worker_registry
 from rq.worker_registration import clean_worker_registry
 
 
-from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job
+from core.utils import (
+    delete_rq_job,
+    enqueue_rq_job,
+    get_db_schema,
+    get_rq_jobs_from_status,
+    requeue_rq_job,
+    stop_rq_job,
+)
 from extras.ui.panels import CustomFieldsPanel, TagsPanel
 from extras.ui.panels import CustomFieldsPanel, TagsPanel
 from netbox.config import PARAMS, get_config
 from netbox.config import PARAMS, get_config
 from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
 from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
+from netbox.plugins import PluginConfig
 from netbox.plugins.utils import get_installed_plugins
 from netbox.plugins.utils import get_installed_plugins
 from netbox.ui import layout
 from netbox.ui import layout
 from netbox.ui.panels import (
 from netbox.ui.panels import (
@@ -674,14 +683,13 @@ class WorkerView(BaseRQView):
 # System
 # System
 #
 #
 
 
+
 class SystemView(UserPassesTestMixin, View):
 class SystemView(UserPassesTestMixin, View):
 
 
     def test_func(self):
     def test_func(self):
         return self.request.user.is_superuser
         return self.request.user.is_superuser
 
 
-    def get(self, request):
-
-        # System status
+    def _get_stats(self):
         psql_version = db_name = db_size = None
         psql_version = db_name = db_size = None
         try:
         try:
             with connection.cursor() as cursor:
             with connection.cursor() as cursor:
@@ -690,11 +698,11 @@ class SystemView(UserPassesTestMixin, View):
                 psql_version = psql_version.split('(')[0].strip()
                 psql_version = psql_version.split('(')[0].strip()
                 cursor.execute("SELECT current_database()")
                 cursor.execute("SELECT current_database()")
                 db_name = cursor.fetchone()[0]
                 db_name = cursor.fetchone()[0]
-                cursor.execute(f"SELECT pg_size_pretty(pg_database_size('{db_name}'))")
+                cursor.execute("SELECT pg_size_pretty(pg_database_size(current_database()))")
                 db_size = cursor.fetchone()[0]
                 db_size = cursor.fetchone()[0]
-        except (ProgrammingError, IndexError):
+        except (DatabaseError, IndexError):
             pass
             pass
-        stats = {
+        return {
             'netbox_release': settings.RELEASE,
             'netbox_release': settings.RELEASE,
             'django_version': django_version,
             'django_version': django_version,
             'python_version': platform.python_version(),
             'python_version': platform.python_version(),
@@ -704,23 +712,23 @@ class SystemView(UserPassesTestMixin, View):
             'rq_worker_count': Worker.count(get_connection('default')),
             'rq_worker_count': Worker.count(get_connection('default')),
         }
         }
 
 
-        # Django apps
-        django_apps = get_installed_apps()
-
-        # Configuration
-        config = get_config()
-
-        # Plugins
-        plugins = get_installed_plugins()
-
-        # Object counts
+    def _get_object_counts(self):
         objects = {}
         objects = {}
         for ot in ObjectType.objects.public().order_by('app_label', 'model'):
         for ot in ObjectType.objects.public().order_by('app_label', 'model'):
             if model := ot.model_class():
             if model := ot.model_class():
                 objects[ot] = model.objects.count()
                 objects[ot] = model.objects.count()
+        return objects
+
+    def get(self, request):
+        stats = self._get_stats()
+        django_apps = get_installed_apps()
+        config = get_config()
+        plugins = get_installed_plugins()
+        objects = self._get_object_counts()
 
 
         # Raw data export
         # Raw data export
         if 'export' in request.GET:
         if 'export' in request.GET:
+            db_schema = get_db_schema()
             stats['netbox_release'] = stats['netbox_release'].asdict()
             stats['netbox_release'] = stats['netbox_release'].asdict()
             params = [param.name for param in PARAMS]
             params = [param.name for param in PARAMS]
             data = {
             data = {
@@ -733,6 +741,12 @@ class SystemView(UserPassesTestMixin, View):
                 'objects': {
                 'objects': {
                     f'{ot.app_label}.{ot.model}': count for ot, count in objects.items()
                     f'{ot.app_label}.{ot.model}': count for ot, count in objects.items()
                 },
                 },
+                'db_schema': {
+                    table['name']: {
+                        'columns': table['columns'],
+                        'indexes': table['indexes'],
+                    } for table in db_schema
+                },
             }
             }
             response = HttpResponse(json.dumps(data, cls=ConfigJSONEncoder, indent=4), content_type='text/json')
             response = HttpResponse(json.dumps(data, cls=ConfigJSONEncoder, indent=4), content_type='text/json')
             response['Content-Disposition'] = 'attachment; filename="netbox.json"'
             response['Content-Disposition'] = 'attachment; filename="netbox.json"'
@@ -752,6 +766,61 @@ class SystemView(UserPassesTestMixin, View):
         })
         })
 
 
 
 
+class SystemDBSchemaView(UserPassesTestMixin, View):
+
+    def test_func(self):
+        return self.request.user.is_superuser
+
+    @staticmethod
+    def _get_db_schema_groups(db_schema):
+        plugin_app_labels = {
+            app_config.label
+            for app_config in django_apps_registry.get_app_configs()
+            if isinstance(app_config, PluginConfig)
+        }
+        # Sort longest-first so "netbox_branching" matches before "netbox"
+        sorted_plugin_labels = sorted(plugin_app_labels, key=len, reverse=True)
+        groups = {}
+        for table in db_schema:
+            matched_plugin = next(
+                (label for label in sorted_plugin_labels if table['name'].startswith(label + '_')),
+                None,
+            )
+            if matched_plugin:
+                prefix = matched_plugin
+            elif '_' in table['name']:
+                prefix = table['name'].split('_')[0]
+            else:
+                prefix = 'other'
+            groups.setdefault(prefix, []).append(table)
+        return sorted(
+            [
+                {
+                    'name': name,
+                    'tables': tables,
+                    'index_count': sum(len(t['indexes']) for t in tables),
+                    'is_plugin': name in plugin_app_labels,
+                }
+                for name, tables in groups.items()
+            ],
+            key=lambda g: (g['is_plugin'], g['name']),
+        )
+
+    def get(self, request):
+        db_schema = get_db_schema()
+        db_schema_groups = self._get_db_schema_groups(db_schema)
+        db_schema_stats = {
+            'total_tables': len(db_schema),
+            'total_columns': sum(len(t['columns']) for t in db_schema),
+            'total_indexes': sum(len(t['indexes']) for t in db_schema),
+        }
+        return render(request, 'core/htmx/system_db_schema.html', {
+            'db_schema': db_schema,
+            'db_schema_groups': db_schema_groups,
+            'db_schema_stats': db_schema_stats,
+        })
+
+
 #
 #
 # Plugins
 # Plugins
 #
 #

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/netbox.css


+ 12 - 0
netbox/project-static/styles/overrides/_tabler.scss

@@ -173,6 +173,18 @@ pre code {
   height: auto;
   height: auto;
 }
 }
 
 
+// Center the accordion chevron inside Tabler's toggle box.
+.accordion-button-toggle {
+  align-items: center;
+  justify-content: center;
+  flex: 0 0 auto;
+
+  > i {
+    display: block;
+    line-height: 1;
+  }
+}
+
 // Theme-based visibility utilities
 // Theme-based visibility utilities
 :root:not(.dummy)[data-bs-theme='light'] .hide-theme-light,
 :root:not(.dummy)[data-bs-theme='light'] .hide-theme-light,
 :root:not(.dummy)[data-bs-theme='dark'] .hide-theme-dark {
 :root:not(.dummy)[data-bs-theme='dark'] .hide-theme-dark {

+ 128 - 0
netbox/templates/core/htmx/system_db_schema.html

@@ -0,0 +1,128 @@
+{% load i18n %}
+{% load humanize %}
+{% if db_schema %}
+  {# Summary boxes #}
+  <div class="row mb-3">
+    <div class="col-md-4">
+      <div class="card text-center">
+        <div class="card-body">
+          <div class="display-6 fw-bold">{{ db_schema_stats.total_tables|intcomma }}</div>
+          <div class="text-muted">{% trans "Tables" %}</div>
+        </div>
+      </div>
+    </div>
+    <div class="col-md-4">
+      <div class="card text-center">
+        <div class="card-body">
+          <div class="display-6 fw-bold">{{ db_schema_stats.total_columns|intcomma }}</div>
+          <div class="text-muted">{% trans "Columns" %}</div>
+        </div>
+      </div>
+    </div>
+    <div class="col-md-4">
+      <div class="card text-center">
+        <div class="card-body">
+          <div class="display-6 fw-bold">{{ db_schema_stats.total_indexes|intcomma }}</div>
+          <div class="text-muted">{% trans "Indexes" %}</div>
+        </div>
+      </div>
+    </div>
+  </div>
+  {# Tables grouped by app prefix #}
+  {% for group in db_schema_groups %}
+    <div class="card mb-3">
+      <h2 class="card-header">
+        <button class="accordion-button collapsed p-0 w-100" type="button"
+                data-bs-toggle="collapse" data-bs-target="#db-group-body-{{ group.name }}"
+                aria-expanded="false" aria-controls="db-group-body-{{ group.name }}">
+          {{ group.name }}
+          {% if group.is_plugin %}<span class="badge text-bg-purple ms-1">{% trans "plugin" %}</span>{% endif %}
+          <span class="badge bg-secondary text-bg-gray ms-1">{{ group.tables|length }} {% trans "tables" %}</span>
+          <span class="badge bg-secondary text-bg-gray ms-1">{{ group.index_count }} {% trans "indexes" %}</span>
+          <span class="accordion-button-toggle"><i class="mdi mdi-chevron-down"></i></span>
+        </button>
+      </h2>
+      <div id="db-group-body-{{ group.name }}" class="collapse">
+        <div class="accordion accordion-flush" id="db-group-{{ group.name }}">
+          {% for table in group.tables %}
+            <div class="accordion-item">
+              <h5 class="accordion-header" id="table-heading-{{ group.name }}-{{ forloop.counter }}">
+                <button class="accordion-button border-bottom collapsed font-monospace" type="button"
+                        data-bs-toggle="collapse" data-bs-target="#table-collapse-{{ group.name }}-{{ forloop.counter }}"
+                        aria-expanded="false" aria-controls="table-collapse-{{ group.name }}-{{ forloop.counter }}">
+                  {{ table.name }}
+                  <span class="badge bg-secondary text-white ms-2">{{ table.columns|length }} {% trans "columns" %}</span>
+                  {% if table.indexes %}
+                    <span class="badge bg-secondary text-white ms-1">{{ table.indexes|length }} {% trans "indexes" %}</span>
+                  {% endif %}
+                  <span class="accordion-button-toggle"><i class="mdi mdi-chevron-down"></i></span>
+                </button>
+              </h5>
+              <div id="table-collapse-{{ group.name }}-{{ forloop.counter }}" class="accordion-collapse collapse"
+                   aria-labelledby="table-heading-{{ group.name }}-{{ forloop.counter }}">
+                <div class="accordion-body p-0">
+                  <div class="px-3 py-2">
+                    <strong>{% trans "Columns" %}</strong>
+                    <table class="table table-hover table-sm mb-0 mt-1">
+                      <thead>
+                        <tr>
+                          <th>{% trans "Column" %}</th>
+                          <th>{% trans "Type" %}</th>
+                          <th>{% trans "Nullable" %}</th>
+                          <th>{% trans "Default" %}</th>
+                        </tr>
+                      </thead>
+                      <tbody>
+                        {% for column in table.columns %}
+                          <tr>
+                            <td class="font-monospace">{{ column.name }}</td>
+                            <td class="font-monospace text-muted">{{ column.type }}</td>
+                            <td>
+                              {% if column.nullable %}
+                                <span class="text-success">{% trans "yes" %}</span>
+                              {% else %}
+                                <span class="text-muted">{% trans "no" %}</span>
+                              {% endif %}
+                            </td>
+                            <td class="font-monospace text-muted">{{ column.default|default:"" }}</td>
+                          </tr>
+                        {% endfor %}
+                      </tbody>
+                    </table>
+                  </div>
+                  {% if table.indexes %}
+                    <div class="px-3 py-2 border-top">
+                      <strong>{% trans "Indexes" %}</strong>
+                      <table class="table table-hover table-sm mb-0 mt-1">
+                        <thead>
+                          <tr>
+                            <th>{% trans "Name" %}</th>
+                            <th>{% trans "Definition" %}</th>
+                          </tr>
+                        </thead>
+                        <tbody>
+                          {% for index in table.indexes %}
+                            <tr>
+                              <td class="font-monospace">{{ index.name }}</td>
+                              <td class="font-monospace text-muted">{{ index.definition }}</td>
+                            </tr>
+                          {% endfor %}
+                        </tbody>
+                      </table>
+                    </div>
+                  {% endif %}
+                </div>
+              </div>
+            </div>
+          {% endfor %}
+        </div>
+      </div>
+    </div>
+  {% endfor %}
+{% else %}
+  <div class="card mb-3">
+    <div class="card-body text-muted">
+      {% trans "Schema information unavailable." %}
+    </div>
+  </div>
+{% endif %}

+ 16 - 0
netbox/templates/core/system.html

@@ -3,6 +3,7 @@
 {% load helpers %}
 {% load helpers %}
 {% load i18n %}
 {% load i18n %}
 {% load render_table from django_tables2 %}
 {% load render_table from django_tables2 %}
+{% load humanize %}
 
 
 {% block title %}{% trans "System" %}{% endblock %}
 {% block title %}{% trans "System" %}{% endblock %}
 
 
@@ -34,6 +35,11 @@
         {% trans "Object Counts" %}
         {% trans "Object Counts" %}
       </a>
       </a>
     </li>
     </li>
+    <li class="nav-item" role="presentation">
+      <a class="nav-link" id="database-tab" data-bs-toggle="tab" data-bs-target="#database-panel" type="button" role="tab">
+        {% trans "Database" %}
+      </a>
+    </li>
   </ul>
   </ul>
 {% endblock tabs %}
 {% endblock tabs %}
 
 
@@ -173,4 +179,14 @@
       </div>
       </div>
     </div>
     </div>
   </div>
   </div>
+  {# Database panel #}
+  <div class="tab-pane" id="database-panel" role="tabpanel" aria-labelledby="database-tab"
+       hx-get="{% url 'core:system_db_schema' %}"
+       hx-trigger="show.bs.tab from:#database-tab once"
+       hx-swap="innerHTML">
+    <div class="text-center text-muted py-5">
+      <div class="spinner-border spinner-border-sm me-2" role="status"></div>
+      {% trans "Loading database schema…" %}
+    </div>
+  </div>
 {% endblock content %}
 {% endblock content %}

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor