Compare commits

...

9 Commits

Author SHA1 Message Date
Arthur
1e73f5d66a use htmx for database tab 2026-04-14 16:59:37 -07:00
Arthur
ac99c4f32c review feedback 2026-04-14 16:45:37 -07:00
Arthur
5a991a7d56 cleanup 2026-04-13 10:44:53 -07:00
Arthur
e002971b60 cleanup 2026-04-13 10:32:49 -07:00
Arthur
86844c1793 cleanup 2026-04-13 09:50:38 -07:00
Arthur
2f5300d6cf cleanup 2026-04-13 09:50:27 -07:00
Arthur
6187b8d344 #21866 Include the PostgreSQL database schema within system details 2026-04-13 09:43:29 -07:00
Arthur
38017d8bd4 #21866 Include the PostgreSQL database schema within system details 2026-04-13 09:23:54 -07:00
Arthur
8eff162b07 #21866 Include the PostgreSQL database schema within system details 2026-04-13 09:11:49 -07:00
5 changed files with 267 additions and 17 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

@@ -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
@@ -26,6 +27,7 @@ from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, r
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 (
@@ -656,14 +658,90 @@ class WorkerView(BaseRQView):
# System
#
def get_db_schema():
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
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']),
)
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:
@@ -672,11 +750,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(),
@@ -686,23 +764,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 = {
@@ -715,6 +793,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"'
@@ -734,6 +818,26 @@ class SystemView(UserPassesTestMixin, View):
})
class SystemDBSchemaView(UserPassesTestMixin, View):
def test_func(self):
return self.request.user.is_superuser
def get(self, request):
db_schema = get_db_schema()
db_schema_groups = 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
#

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