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:
parent
a451e12158
commit
900f1155af
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
Loading…
Reference in New Issue