mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-15 13:39:54 +02:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e73f5d66a | ||
|
|
ac99c4f32c | ||
|
|
5a991a7d56 | ||
|
|
e002971b60 | ||
|
|
86844c1793 | ||
|
|
2f5300d6cf | ||
|
|
6187b8d344 | ||
|
|
38017d8bd4 | ||
|
|
8eff162b07 |
@@ -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()
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 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 (
|
||||||
@@ -656,14 +658,90 @@ class WorkerView(BaseRQView):
|
|||||||
# System
|
# 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):
|
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):
|
def _get_stats(self):
|
||||||
|
|
||||||
# System status
|
|
||||||
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:
|
||||||
@@ -672,11 +750,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(),
|
||||||
@@ -686,23 +764,23 @@ class SystemView(UserPassesTestMixin, View):
|
|||||||
'rq_worker_count': Worker.count(get_connection('default')),
|
'rq_worker_count': Worker.count(get_connection('default')),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Django apps
|
def _get_object_counts(self):
|
||||||
django_apps = get_installed_apps()
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
config = get_config()
|
|
||||||
|
|
||||||
# Plugins
|
|
||||||
plugins = get_installed_plugins()
|
|
||||||
|
|
||||||
# Object counts
|
|
||||||
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 = {
|
||||||
@@ -715,6 +793,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"'
|
||||||
@@ -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
|
# Plugins
|
||||||
#
|
#
|
||||||
|
|||||||
128
netbox/templates/core/htmx/system_db_schema.html
Normal file
128
netbox/templates/core/htmx/system_db_schema.html
Normal 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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user