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.
This commit is contained in:
Arthur Hanson 2026-04-20 09:18:10 -07:00 committed by GitHub
parent a451e12158
commit 900f1155af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 295 additions and 19 deletions

View File

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

View File

@ -50,6 +50,7 @@ urlpatterns = (
path('config-revisions/<int:pk>/', include(get_model_urls('core', 'configrevision'))),
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/<str:name>/', views.PluginView.as_view(), name='plugin'),

View File

@ -1,3 +1,4 @@
from django.db import DatabaseError, connection
from django.http import Http404
from django.utils.translation import gettext_lazy as _
from django_rq.queues import get_queue, get_queue_by_index, get_redis_connection
@ -18,6 +19,7 @@ from rq.registry import (
__all__ = (
'delete_rq_job',
'enqueue_rq_job',
'get_db_schema',
'get_rq_jobs',
'get_rq_jobs_from_status',
'requeue_rq_job',
@ -154,3 +156,50 @@ def stop_rq_job(job_id):
queue = get_queue_by_index(queue_index)
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

View File

@ -3,11 +3,12 @@ import platform
from copy import deepcopy
from django import __version__ as django_version
from django.apps import apps as django_apps_registry
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import UserPassesTestMixin
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.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@ -22,10 +23,18 @@ from rq.job import JobStatus as RQJobStatus
from rq.worker import Worker
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 netbox.config import PARAMS, get_config
from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
from netbox.plugins import PluginConfig
from netbox.plugins.utils import get_installed_plugins
from netbox.ui import layout
from netbox.ui.panels import (
@ -674,14 +683,13 @@ class WorkerView(BaseRQView):
# System
#
class SystemView(UserPassesTestMixin, View):
def test_func(self):
return self.request.user.is_superuser
def get(self, request):
# System status
def _get_stats(self):
psql_version = db_name = db_size = None
try:
with connection.cursor() as cursor:
@ -690,11 +698,11 @@ class SystemView(UserPassesTestMixin, View):
psql_version = psql_version.split('(')[0].strip()
cursor.execute("SELECT current_database()")
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]
except (ProgrammingError, IndexError):
except (DatabaseError, IndexError):
pass
stats = {
return {
'netbox_release': settings.RELEASE,
'django_version': django_version,
'python_version': platform.python_version(),
@ -704,23 +712,23 @@ class SystemView(UserPassesTestMixin, View):
'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 = {}
for ot in ObjectType.objects.public().order_by('app_label', 'model'):
if model := ot.model_class():
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
if 'export' in request.GET:
db_schema = get_db_schema()
stats['netbox_release'] = stats['netbox_release'].asdict()
params = [param.name for param in PARAMS]
data = {
@ -733,6 +741,12 @@ class SystemView(UserPassesTestMixin, View):
'objects': {
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['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
#

File diff suppressed because one or more lines are too long

View File

@ -173,6 +173,18 @@ pre code {
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
:root:not(.dummy)[data-bs-theme='light'] .hide-theme-light,
:root:not(.dummy)[data-bs-theme='dark'] .hide-theme-dark {

View File

@ -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 %}

View File

@ -3,6 +3,7 @@
{% load helpers %}
{% load i18n %}
{% load render_table from django_tables2 %}
{% load humanize %}
{% block title %}{% trans "System" %}{% endblock %}
@ -34,6 +35,11 @@
{% trans "Object Counts" %}
</a>
</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>
{% endblock tabs %}
@ -173,4 +179,14 @@
</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 %}