Closes #15915: Replace plugins list with an overall system status view (#15950)

* Replace plugins list with an overall system status view

* Enable export of system status data
This commit is contained in:
Jeremy Stretch 2024-05-03 17:26:19 -04:00 committed by GitHub
parent a9b311b100
commit 8e1c2ecd92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 315 additions and 239 deletions

View File

@ -35,5 +35,5 @@ class PluginTable(BaseTable):
'name', 'version', 'package', 'author', 'author_email', 'description',
)
default_columns = (
'name', 'version', 'package', 'author', 'author_email', 'description',
'name', 'version', 'package', 'description',
)

View File

@ -43,9 +43,6 @@ urlpatterns = (
path('config-revisions/<int:pk>/restore/', views.ConfigRevisionRestoreView.as_view(), name='configrevision_restore'),
path('config-revisions/<int:pk>/', include(get_model_urls('core', 'configrevision'))),
# Configuration
path('config/', views.ConfigView.as_view(), name='config'),
# Plugins
path('plugins/', views.PluginListView.as_view(), name='plugin_list'),
# System
path('system/', views.SystemView.as_view(), name='system'),
)

View File

@ -1,14 +1,19 @@
import json
import platform
from django import __version__ as DJANGO_VERSION
from django.apps import apps
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.http import HttpResponseForbidden, Http404
from django.db import connection, ProgrammingError
from django.http import HttpResponse, HttpResponseForbidden, Http404
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import View
from django_rq.queues import get_queue_by_index, get_redis_connection
from django_rq.queues import get_connection, get_queue_by_index, get_redis_connection
from django_rq.settings import QUEUES_MAP, QUEUES_LIST
from django_rq.utils import get_jobs, get_statistics, stop_jobs
from rq import requeue_job
@ -175,20 +180,6 @@ class JobBulkDeleteView(generic.BulkDeleteView):
# Config Revisions
#
class ConfigView(generic.ObjectView):
queryset = ConfigRevision.objects.all()
def get_object(self, **kwargs):
revision_id = cache.get('config_version')
try:
return ConfigRevision.objects.get(pk=revision_id)
except ConfigRevision.DoesNotExist:
# Fall back to using the active config data if no record is found
return ConfigRevision(
data=get_config().defaults
)
class ConfigRevisionListView(generic.ObjectListView):
queryset = ConfigRevision.objects.all()
filterset = filtersets.ConfigRevisionFilterSet
@ -527,21 +518,69 @@ class WorkerView(BaseRQView):
# Plugins
#
class PluginListView(UserPassesTestMixin, View):
class SystemView(UserPassesTestMixin, View):
def test_func(self):
return self.request.user.is_staff
def get(self, request):
# System stats
psql_version = db_name = db_size = None
try:
with connection.cursor() as cursor:
cursor.execute("SELECT version()")
psql_version = cursor.fetchone()[0]
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}'))")
db_size = cursor.fetchone()[0]
except (ProgrammingError, IndexError):
pass
stats = {
'netbox_version': settings.VERSION,
'django_version': DJANGO_VERSION,
'python_version': platform.python_version(),
'postgresql_version': psql_version,
'database_name': db_name,
'database_size': db_size,
'rq_worker_count': Worker.count(get_connection('default')),
}
# Plugins
plugins = [
# Look up app config by package name
apps.get_app_config(plugin.rsplit('.', 1)[-1]) for plugin in settings.PLUGINS
]
table = tables.PluginTable(plugins, user=request.user)
table.configure(request)
return render(request, 'core/plugin_list.html', {
'plugins': plugins,
'active_tab': 'api-tokens',
'table': table,
# Configuration
try:
config = ConfigRevision.objects.get(pk=cache.get('config_version'))
except ConfigRevision.DoesNotExist:
# Fall back to using the active config data if no record is found
config = ConfigRevision(data=get_config().defaults)
# Raw data export
if 'export' in request.GET:
data = {
**stats,
'plugins': {
plugin.name: plugin.version for plugin in plugins
},
'config': {
k: config.data[k] for k in sorted(config.data)
},
}
response = HttpResponse(json.dumps(data, indent=4), content_type='text/json')
response['Content-Disposition'] = 'attachment; filename="netbox.json"'
return response
plugins_table = tables.PluginTable(plugins, orderable=False)
plugins_table.configure(request)
return render(request, 'core/system.html', {
'stats': stats,
'plugins_table': plugins_table,
'config': config,
})

View File

@ -420,27 +420,17 @@ ADMIN_MENU = Menu(
),
),
),
MenuGroup(
label=_('Configuration'),
items=(
MenuItem(
link='core:config',
link_text=_('Current Config'),
permissions=['core.view_configrevision']
),
MenuItem(
link='core:configrevision_list',
link_text=_('Config Revisions'),
permissions=['core.view_configrevision']
),
),
),
MenuGroup(
label=_('System'),
items=(
MenuItem(
link='core:plugin_list',
link_text=_('Plugins')
link='core:system',
link_text=_('System')
),
MenuItem(
link='core:configrevision_list',
link_text=_('Configuration History'),
permissions=['core.view_configrevision']
),
MenuItem(
link='core:background_queue_list',

View File

@ -32,163 +32,8 @@
<div class="row">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">{% trans "Rack Elevations" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Default unit height" %}</th>
<td>{{ object.data.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT }}</td>
</tr>
<tr>
<th scope="row">{% trans "Default unit width" %}</th>
<td>{{ object.data.RACK_ELEVATION_DEFAULT_UNIT_WIDTH }}</td>
</tr>
</table>
</div>
<div class="card">
<h5 class="card-header">{% trans "Power Feeds" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Default voltage" %}</th>
<td>{{ object.data.POWERFEED_DEFAULT_VOLTAGE }}</td>
</tr>
<tr>
<th scope="row">{% trans "Default amperage" %}</th>
<td>{{ object.data.POWERFEED_DEFAULT_AMPERAGE }}</td>
</tr>
<tr>
<th scope="row">{% trans "Default max utilization" %}</th>
<td>{{ object.data.POWERFEED_DEFAULT_MAX_UTILIZATION }}</td>
</tr>
</table>
</div>
<div class="card">
<h5 class="card-header">{% trans "IPAM" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Enforce global unique" %}</th>
<td>{{ object.data.ENFORCE_GLOBAL_UNIQUE }}</td>
</tr>
<tr>
<th scope="row">{% trans "Prefer IPv4" %}</th>
<td>{{ object.data.PREFER_IPV4 }}</td>
</tr>
</table>
</div>
<div class="card">
<h5 class="card-header">{% trans "Security" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Allowed URL schemes" %}</th>
<td>{{ object.data.ALLOWED_URL_SCHEMES|join:", "|placeholder }}</td>
</tr>
</table>
</div>
<div class="card">
<h5 class="card-header">{% trans "Banners" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Login banner" %}</th>
<td>{{ object.data.BANNER_LOGIN }}</td>
</tr>
<tr>
<th scope="row">{% trans "Maintenance banner" %}</th>
<td>{{ object.data.BANNER_MAINTENANCE }}</td>
</tr>
<tr>
<th scope="row">{% trans "Top banner" %}</th>
<td>{{ object.data.BANNER_TOP }}</td>
</tr>
<tr>
<th scope="row">{% trans "Bottom banner" %}</th>
<td>{{ object.data.BANNER_BOTTOM }}</td>
</tr>
</table>
</div>
<div class="card">
<h5 class="card-header">{% trans "Pagination" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Paginate count" %}</th>
<td>{{ object.data.PAGINATE_COUNT }}</td>
</tr>
<tr>
<th scope="row">{% trans "Max page size" %}</th>
<td>{{ object.data.MAX_PAGE_SIZE }}</td>
</tr>
</table>
</div>
<div class="card">
<h5 class="card-header">{% trans "Validation" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Custom validators" %}</th>
{% if object.data.CUSTOM_VALIDATORS %}
<td class="font-monospace">
<pre>{{ object.data.CUSTOM_VALIDATORS|json }}</pre>
</td>
{% else %}
<td>{{ ''|placeholder }}</td>
{% endif %}
</tr>
<tr>
<th scope="row">{% trans "Protection rules" %}</th>
{% if object.data.PROTECTION_RULES %}
<td>
<pre>{{ object.data.PROTECTION_RULES|json }}</pre>
</td>
{% else %}
<td>{{ ''|placeholder }}</td>
{% endif %}
</tr>
</table>
</div>
<div class="card">
<h5 class="card-header">{% trans "User Preferences" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Default user preferences" %}</th>
{% if object.data.DEFAULT_USER_PREFERENCES %}
<td>
<pre>{{ object.data.DEFAULT_USER_PREFERENCES|json }}</pre>
</td>
{% else %}
<td>{{ ''|placeholder }}</td>
{% endif %}
</tr>
</table>
</div>
<div class="card">
<h5 class="card-header">{% trans "Miscellaneous" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Maintenance mode" %}</th>
<td>{{ object.data.MAINTENANCE_MODE }}</td>
</tr>
<tr>
<th scope="row">{% trans "GraphQL enabled" %}</th>
<td>{{ object.data.GRAPHQL_ENABLED }}</td>
</tr>
<tr>
<th scope="row">{% trans "Changelog retention" %}</th>
<td>{{ object.data.CHANGELOG_RETENTION }}</td>
</tr>
<tr>
<th scope="row">{% trans "Job retention" %}</th>
<td>{{ object.data.JOB_RETENTION }}</td>
</tr>
<tr>
<th scope="row">{% trans "Maps URL" %}</th>
<td>{{ object.data.MAPS_URL }}</td>
</tr>
</table>
<h5 class="card-header">{% trans "Configuration Data" %}</h5>
{% include 'core/inc/config_data.html' with config=config.data %}
</div>
<div class="card">

View File

@ -0,0 +1,149 @@
{% load i18n %}
<table class="table table-hover attr-table">
{# Rack elevations #}
<tr>
<td colspan="2" class="bg-secondary-subtle fs-5 fw-bold border-0 py-1">{% trans "Rack elevations" %}</td>
</tr>
<tr>
<th scope="row" class="ps-3">{% trans "Default unit height" %}</th>
<td>{{ config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT }}</td>
</tr>
<tr>
<th scope="row" class="border-0 ps-3">{% trans "Default unit width" %}</th>
<td class="border-0">{{ config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH }}</td>
</tr>
{# Power feeds #}
<tr>
<td colspan="2" class="bg-secondary-subtle fs-5 fw-bold border-0 py-1">{% trans "Power feeds" %}</td>
</tr>
<tr>
<th scope="row" class="ps-3">{% trans "Default voltage" %}</th>
<td>{{ config.POWERFEED_DEFAULT_VOLTAGE }}</td>
</tr>
<tr>
<th scope="row" class="ps-3">{% trans "Default amperage" %}</th>
<td>{{ config.POWERFEED_DEFAULT_AMPERAGE }}</td>
</tr>
<tr>
<th scope="row" class="border-0 ps-3">{% trans "Default max utilization" %}</th>
<td class="border-0">{{ config.POWERFEED_DEFAULT_MAX_UTILIZATION }}</td>
</tr>
{# IPAM #}
<tr>
<td colspan="2" class="bg-secondary-subtle fs-5 fw-bold border-0 py-1">{% trans "IPAM" %}</td>
</tr>
<tr>
<th scope="row" class="ps-3">{% trans "Enforce global unique" %}</th>
<td>{% checkmark config.ENFORCE_GLOBAL_UNIQUE %}</td>
</tr>
<tr>
<th scope="row" class="border-0 ps-3">{% trans "Prefer IPv4" %}</th>
<td class="border-0">{% checkmark config.PREFER_IPV4 %}</td>
</tr>
{# Security #}
<tr>
<td colspan="2" class="bg-secondary-subtle fs-5 fw-bold border-0 py-1">{% trans "Security" %}</td>
</tr>
<tr>
<th scope="row" class="border-0 ps-3">{% trans "Allowed URL schemes" %}</th>
<td class="border-0">{{ config.ALLOWED_URL_SCHEMES|join:", "|placeholder }}</td>
</tr>
{# Banners #}
<tr>
<td colspan="2" class="bg-secondary-subtle fs-5 fw-bold border-0 py-1">{% trans "Banners" %}</td>
</tr>
<tr>
<th scope="row" class="ps-3">{% trans "Login banner" %}</th>
<td>{{ config.BANNER_LOGIN|placeholder }}</td>
</tr>
<tr>
<th scope="row" class="ps-3">{% trans "Maintenance banner" %}</th>
<td>{{ config.BANNER_MAINTENANCE|placeholder }}</td>
</tr>
<tr>
<th scope="row" class="ps-3">{% trans "Top banner" %}</th>
<td>{{ config.BANNER_TOP|placeholder }}</td>
</tr>
<tr>
<th scope="row" class="border-0 ps-3">{% trans "Bottom banner" %}</th>
<td class="border-0">{{ config.BANNER_BOTTOM|placeholder }}</td>
</tr>
{# Pagination #}
<tr>
<td colspan="2" class="bg-secondary-subtle fs-5 fw-bold border-0 py-1">{% trans "Pagination" %}</td>
</tr>
<tr>
<th scope="row" class="ps-3">{% trans "Paginate count" %}</th>
<td>{{ config.PAGINATE_COUNT }}</td>
</tr>
<tr>
<th scope="row" class="border-0 ps-3">{% trans "Max page size" %}</th>
<td class="border-0">{{ config.MAX_PAGE_SIZE }}</td>
</tr>
{# Validation #}
<tr>
<td colspan="2" class="bg-secondary-subtle fs-5 fw-bold border-0 py-1">{% trans "Validation" %}</td>
</tr>
<tr>
<th scope="row" class="ps-3">{% trans "Custom validators" %}</th>
{% if config.CUSTOM_VALIDATORS %}
<td><pre>{{ config.CUSTOM_VALIDATORS|json }}</pre></td>
{% else %}
<td>{{ ''|placeholder }}</td>
{% endif %}
</tr>
<tr>
<th scope="row" class="border-0 ps-3">{% trans "Protection rules" %}</th>
{% if config.PROTECTION_RULES %}
<td class="border-0"><pre>{{ config.PROTECTION_RULES|json }}</pre></td>
{% else %}
<td class="border-0">{{ ''|placeholder }}</td>
{% endif %}
</tr>
{# User Preferences #}
<tr>
<td colspan="2" class="bg-secondary-subtle fs-5 fw-bold border-0 py-1">{% trans "User preferences" %}</td>
</tr>
<tr>
<th scope="row" class="border-0 ps-3">{% trans "Default preferences" %}</th>
{% if config.DEFAULT_USER_PREFERENCES %}
<td class="border-0"><pre>{{ config.DEFAULT_USER_PREFERENCES|json }}</pre></td>
{% else %}
<td class="border-0">{{ ''|placeholder }}</td>
{% endif %}
</tr>
{# Miscellaneous #}
<tr>
<td colspan="2" class="bg-secondary-subtle fs-5 fw-bold border-0 py-1">{% trans "Miscellaneous" %}</td>
<tr>
<th scope="row" class="ps-3">{% trans "Maintenance mode" %}</th>
<td>{% checkmark config.MAINTENANCE_MODE %}</td>
</tr>
<tr>
<th scope="row" class="ps-3">{% trans "GraphQL enabled" %}</th>
<td>{% checkmark config.GRAPHQL_ENABLED %}</td>
</tr>
<tr>
<th scope="row" class="ps-3">{% trans "Changelog retention" %}</th>
<td>{{ config.CHANGELOG_RETENTION }}</td>
</tr>
<tr>
<th scope="row" class="ps-3">{% trans "Job retention" %}</th>
<td>{{ config.JOB_RETENTION }}</td>
</tr>
<tr>
<th scope="row" class="border-0 ps-3">{% trans "Maps URL" %}</th>
<td class="border-0">{{ config.MAPS_URL }}</td>
</tr>
</table>

View File

@ -1,36 +0,0 @@
{% extends 'generic/_base.html' %}
{% load buttons %}
{% load helpers %}
{% load i18n %}
{% load render_table from django_tables2 %}
{% block title %}{% trans "Plugins" %}{% endblock %}
{% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
<a class="nav-link active" role="tab">{% trans "Plugins" %}</a>
</li>
</ul>
{% endblock tabs %}
{% block content %}
<div class="row mb-3">
<div class="col-auto ms-auto d-print-none">
{# Table configuration button #}
<div class="table-configure input-group">
<button type="button" data-bs-toggle="modal" title="{% trans "Configure Table" %}" data-bs-target="#ObjectTable_config" class="btn">
<i class="mdi mdi-cog"></i> {% trans "Configure Table" %}
</button>
</div>
</div>
</div>
<div class="card">
{% render_table table %}
</div>
{% endblock content %}
{% block modals %}
{% table_config_form table table_name="ObjectTable" %}
{% endblock modals %}

View File

@ -0,0 +1,92 @@
{% extends 'generic/_base.html' %}
{% load buttons %}
{% load helpers %}
{% load i18n %}
{% load render_table from django_tables2 %}
{% block title %}{% trans "System" %}{% endblock %}
{% block controls %}
<a href="?export=true" class="btn btn-purple">
<i class="mdi mdi-download"></i> {% trans "Export" %}
</a>
{% endblock controls %}
{% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
<a class="nav-link active" role="tab">{% trans "Status" %}</a>
</li>
</ul>
{% endblock tabs %}
{% block content %}
{# System status #}
<div class="row mb-3">
<div class="col">
<div class="card">
<h5 class="card-header">{% trans "System Status" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "NetBox version" %}</th>
<td>{{ stats.netbox_version }}</td>
</tr>
<tr>
<th scope="row">{% trans "Django version" %}</th>
<td>{{ stats.django_version }}</td>
</tr>
<tr>
<th scope="row">{% trans "PotsgreSQL version" %}</th>
<td>{{ stats.postgresql_version }}</td>
</tr>
<tr>
<th scope="row">{% trans "Database name" %}</th>
<td>{{ stats.database_name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Database size" %}</th>
<td>
{% if stats.database_size %}
{{ stats.database_size }}
{% else %}
<span class="text-muted">{% trans "Unavailable" %}</span>
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "RQ workers" %}</th>
<td>
<a href="{% url 'core:background_queue_list' %}">{{ stats.rq_worker_count }}</a>
({% trans "default queue" %})
</td>
</tr>
<tr>
<th scope="row">{% trans "System time" %}</th>
<td>{% now 'Y-m-d H:i:s T' %}</td>
</tr>
</table>
</div>
</div>
</div>
{# Plugins #}
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">{% trans "Plugins" %}</h5>
{% render_table plugins_table %}
</div>
</div>
</div>
{# Configuration #}
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">{% trans "Current Configuration" %}</h5>
{% include 'core/inc/config_data.html' with config=config.data %}
</div>
</div>
</div>
{% endblock content %}