mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-14 05:00:13 +02:00
Compare commits
9 Commits
release-v4
...
21866-sql
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a991a7d56 | ||
|
|
e002971b60 | ||
|
|
86844c1793 | ||
|
|
2f5300d6cf | ||
|
|
6187b8d344 | ||
|
|
38017d8bd4 | ||
|
|
8eff162b07 | ||
|
|
9b734bac93 | ||
|
|
0f277894b2 |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -56,9 +56,9 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check Python linting & PEP8 compliance
|
||||
uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 # v3.6.1
|
||||
uses: astral-sh/ruff-action@0ce1b0bf8b818ef400413f810f8a11cdbda0034b # v4.0.0
|
||||
with:
|
||||
version: "0.15.2"
|
||||
version: "0.15.10"
|
||||
args: "check --output-format=github"
|
||||
src: "netbox/"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Create app token
|
||||
uses: actions/create-github-app-token@v1
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: 1076524
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
run: python netbox/manage.py makemessages -l ${{ env.LOCALE }}
|
||||
|
||||
- name: Commit changes
|
||||
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
|
||||
uses: EndBug/add-and-commit@290ea2c423ad77ca9c62ae0f5b224379612c0321 # v10.0.0
|
||||
with:
|
||||
add: 'netbox/translations/'
|
||||
default_author: github_actions
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 (
|
||||
@@ -661,9 +663,7 @@ 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 +672,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,20 +686,102 @@ 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_db_schema(self):
|
||||
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 = 'public'
|
||||
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 = 'public'
|
||||
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(self, 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):
|
||||
stats = self._get_stats()
|
||||
django_apps = get_installed_apps()
|
||||
config = get_config()
|
||||
plugins = get_installed_plugins()
|
||||
objects = self._get_object_counts()
|
||||
db_schema = self._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),
|
||||
}
|
||||
|
||||
# Raw data export
|
||||
if 'export' in request.GET:
|
||||
@@ -715,6 +797,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"'
|
||||
@@ -731,6 +819,9 @@ class SystemView(UserPassesTestMixin, View):
|
||||
'config': config,
|
||||
'plugins': plugins,
|
||||
'objects': objects,
|
||||
'db_schema': db_schema,
|
||||
'db_schema_groups': db_schema_groups,
|
||||
'db_schema_stats': db_schema_stats,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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,133 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{# Database panel #}
|
||||
<div class="tab-pane" id="database-panel" role="tabpanel" aria-labelledby="database-tab">
|
||||
{% 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 bg-purple text-white ms-1">{% trans "plugin" %}</span>{% endif %}
|
||||
<span class="badge bg-secondary text-white ms-1">{{ group.tables|length }} {% trans "tables" %}</span>
|
||||
<span class="badge bg-secondary text-white 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">
|
||||
<h3 class="accordion-header" id="table-heading-{{ group.name }}-{{ forloop.counter }}">
|
||||
<button class="accordion-button 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>
|
||||
</h3>
|
||||
<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 %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
Reference in New Issue
Block a user