mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-15 13:39:54 +02:00
#21866 Include the PostgreSQL database schema within system details
This commit is contained in:
@@ -701,6 +701,69 @@ class SystemView(UserPassesTestMixin, View):
|
||||
if model := ot.model_class():
|
||||
objects[ot] = model.objects.count()
|
||||
|
||||
# Database schema
|
||||
db_schema = []
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
# Fetch all columns for all public tables in one query
|
||||
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,
|
||||
})
|
||||
|
||||
# Fetch all indexes for all public tables in one query
|
||||
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 ProgrammingError:
|
||||
pass
|
||||
|
||||
# Group tables by app prefix (e.g. "dcim", "ipam")
|
||||
_groups = {}
|
||||
for table in db_schema:
|
||||
prefix = table['name'].split('_')[0] if '_' in table['name'] else 'other'
|
||||
_groups.setdefault(prefix, []).append(table)
|
||||
db_schema_groups = [
|
||||
{
|
||||
'name': name,
|
||||
'tables': tables,
|
||||
'index_count': sum(len(t['indexes']) for t in tables),
|
||||
}
|
||||
for name, tables in sorted(_groups.items())
|
||||
]
|
||||
|
||||
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:
|
||||
stats['netbox_release'] = stats['netbox_release'].asdict()
|
||||
@@ -715,6 +778,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 +800,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,135 @@
|
||||
</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 #}
|
||||
<style>
|
||||
.db-group-chevron, .db-table-chevron { transition: transform 0.2s; }
|
||||
[aria-expanded="true"] .db-group-chevron,
|
||||
[aria-expanded="true"] .db-table-chevron { transform: rotate(90deg); }
|
||||
</style>
|
||||
{% for group in db_schema_groups %}
|
||||
<div class="card mb-3">
|
||||
<h2 class="card-header d-flex align-items-center gap-2" role="button" tabindex="0"
|
||||
style="cursor: pointer; user-select: none;"
|
||||
data-bs-toggle="collapse" data-bs-target="#db-group-body-{{ group.name }}"
|
||||
aria-expanded="false" aria-controls="db-group-body-{{ group.name }}">
|
||||
<i class="mdi mdi-chevron-right db-group-chevron"></i>
|
||||
{{ group.name }}
|
||||
<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>
|
||||
</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 }}">
|
||||
<i class="mdi mdi-chevron-right db-table-chevron me-2"></i>{{ 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 %}
|
||||
</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