Merge pull request #8957 from kkthxbye-code/save-job-results

Fix #8956: Save old JobResults
This commit is contained in:
Jeremy Stretch 2022-04-12 11:57:48 -04:00 committed by GitHub
commit 68b8cca540
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 55 additions and 41 deletions

View File

@ -4,6 +4,7 @@ NetBox includes a `housekeeping` management command that should be run nightly.
* Clearing expired authentication sessions from the database * Clearing expired authentication sessions from the database
* Deleting changelog records older than the configured [retention time](../configuration/dynamic-settings.md#changelog_retention) * Deleting changelog records older than the configured [retention time](../configuration/dynamic-settings.md#changelog_retention)
* Deleting job result records older than the configured [retention time](../configuration/dynamic-settings.md#jobresult_retention)
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file. This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.

View File

@ -43,6 +43,18 @@ changes in the database indefinitely.
--- ---
## JOBRESULT_RETENTION
Default: 90
The number of days to retain job results (scripts and reports). Set this to `0` to retain
job results in the database indefinitely.
!!! warning
If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity.
---
## CUSTOM_VALIDATORS ## CUSTOM_VALIDATORS
This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. An example is provided below: This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. An example is provided below:

View File

@ -40,7 +40,7 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
'fields': ('DEFAULT_USER_PREFERENCES',), 'fields': ('DEFAULT_USER_PREFERENCES',),
}), }),
('Miscellaneous', { ('Miscellaneous', {
'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'MAPS_URL'), 'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOBRESULT_RETENTION', 'MAPS_URL'),
}), }),
('Config Revision', { ('Config Revision', {
'fields': ('comment',), 'fields': ('comment',),

View File

@ -179,7 +179,7 @@ class ReportViewSet(ViewSet):
for r in JobResult.objects.filter( for r in JobResult.objects.filter(
obj_type=report_content_type, obj_type=report_content_type,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).defer('data') ).order_by('name', '-created').distinct('name').defer('data')
} }
# Iterate through all available Reports. # Iterate through all available Reports.
@ -271,7 +271,7 @@ class ScriptViewSet(ViewSet):
for r in JobResult.objects.filter( for r in JobResult.objects.filter(
obj_type=script_content_type, obj_type=script_content_type,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).defer('data').order_by('created') ).order_by('name', '-created').distinct('name').defer('data')
} }
flat_list = [] flat_list = []

View File

@ -9,6 +9,7 @@ from django.db import DEFAULT_DB_ALIAS
from django.utils import timezone from django.utils import timezone
from packaging import version from packaging import version
from extras.models import JobResult
from extras.models import ObjectChange from extras.models import ObjectChange
from netbox.config import Config from netbox.config import Config
@ -63,6 +64,33 @@ class Command(BaseCommand):
f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {config.CHANGELOG_RETENTION})" f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {config.CHANGELOG_RETENTION})"
) )
# Delete expired JobResults
if options['verbosity']:
self.stdout.write("[*] Checking for expired jobresult records")
if config.JOBRESULT_RETENTION:
cutoff = timezone.now() - timedelta(days=config.JOBRESULT_RETENTION)
if options['verbosity'] >= 2:
self.stdout.write(f"\tRetention period: {config.JOBRESULT_RETENTION} days")
self.stdout.write(f"\tCut-off time: {cutoff}")
expired_records = JobResult.objects.filter(created__lt=cutoff).count()
if expired_records:
if options['verbosity']:
self.stdout.write(
f"\tDeleting {expired_records} expired records... ",
self.style.WARNING,
ending=""
)
self.stdout.flush()
JobResult.objects.filter(created__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
if options['verbosity']:
self.stdout.write("Done.", self.style.SUCCESS)
elif options['verbosity']:
self.stdout.write("\tNo expired records found.", self.style.SUCCESS)
elif options['verbosity']:
self.stdout.write(
f"\tSkipping: No retention period specified (JOBRESULT_RETENTION = {config.JOBRESULT_RETENTION})"
)
# Check for new releases (if enabled) # Check for new releases (if enabled)
if options['verbosity']: if options['verbosity']:
self.stdout.write("[*] Checking for latest release") self.stdout.write("[*] Checking for latest release")

View File

@ -113,13 +113,6 @@ class Command(BaseCommand):
script_content_type = ContentType.objects.get(app_label='extras', model='script') script_content_type = ContentType.objects.get(app_label='extras', model='script')
# Delete any previous terminal state results
JobResult.objects.filter(
obj_type=script_content_type,
name=script.full_name,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).delete()
# Create the job result # Create the job result
job_result = JobResult.objects.create( job_result = JobResult.objects.create(
name=script.full_name, name=script.full_name,

View File

@ -84,15 +84,6 @@ def run_report(job_result, *args, **kwargs):
job_result.save() job_result.save()
logging.error(f"Error during execution of report {job_result.name}") logging.error(f"Error during execution of report {job_result.name}")
# Delete any previous terminal state results
JobResult.objects.filter(
obj_type=job_result.obj_type,
name=job_result.name,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).exclude(
pk=job_result.pk
).delete()
class Report(object): class Report(object):
""" """

View File

@ -481,15 +481,6 @@ def run_script(data, request, commit=True, *args, **kwargs):
else: else:
_run_script() _run_script()
# Delete any previous terminal state results
JobResult.objects.filter(
obj_type=job_result.obj_type,
name=job_result.name,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).exclude(
pk=job_result.pk
).delete()
def get_scripts(use_names=False): def get_scripts(use_names=False):
""" """
@ -497,7 +488,7 @@ def get_scripts(use_names=False):
defined name in place of the actual module name. defined name in place of the actual module name.
""" """
scripts = OrderedDict() scripts = OrderedDict()
# Iterate through all modules within the reports path. These are the user-created files in which reports are # Iterate through all modules within the scripts path. These are the user-created files in which reports are
# defined. # defined.
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]): for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
# Remove cached module to ensure consistency with filesystem # Remove cached module to ensure consistency with filesystem

View File

@ -524,7 +524,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
for r in JobResult.objects.filter( for r in JobResult.objects.filter(
obj_type=report_content_type, obj_type=report_content_type,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).defer('data') ).order_by('name', '-created').distinct('name').defer('data')
} }
ret = [] ret = []
@ -656,7 +656,7 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
for r in JobResult.objects.filter( for r in JobResult.objects.filter(
obj_type=script_content_type, obj_type=script_content_type,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).defer('data') ).order_by('name', '-created').distinct('name').defer('data')
} }
for _scripts in scripts.values(): for _scripts in scripts.values():

View File

@ -187,6 +187,13 @@ PARAMS = (
description="Days to retain changelog history (set to zero for unlimited)", description="Days to retain changelog history (set to zero for unlimited)",
field=forms.IntegerField field=forms.IntegerField
), ),
ConfigParam(
name='JOBRESULT_RETENTION',
label='Job result retention',
default=90,
description="Days to retain job result history (set to zero for unlimited)",
field=forms.IntegerField
),
ConfigParam( ConfigParam(
name='MAPS_URL', name='MAPS_URL',
label='Maps URL', label='Maps URL',

View File

@ -19,8 +19,7 @@ from circuits.models import Circuit, Provider
from dcim.models import ( from dcim.models import (
Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, Site, Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, Site,
) )
from extras.choices import JobResultStatusChoices from extras.models import ObjectChange
from extras.models import ObjectChange, JobResult
from extras.tables import ObjectChangeTable from extras.tables import ObjectChangeTable
from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES
@ -48,13 +47,6 @@ class HomeView(View):
pk__lt=F('_path__destination_id') pk__lt=F('_path__destination_id')
) )
# Report Results
report_content_type = ContentType.objects.get(app_label='extras', model='report')
report_results = JobResult.objects.filter(
obj_type=report_content_type,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).defer('data')[:10]
def build_stats(): def build_stats():
org = ( org = (
("dcim.view_site", "Sites", Site.objects.restrict(request.user, 'view').count), ("dcim.view_site", "Sites", Site.objects.restrict(request.user, 'view').count),
@ -150,7 +142,6 @@ class HomeView(View):
return render(request, self.template_name, { return render(request, self.template_name, {
'search_form': SearchForm(), 'search_form': SearchForm(),
'stats': build_stats(), 'stats': build_stats(),
'report_results': report_results,
'changelog_table': changelog_table, 'changelog_table': changelog_table,
'new_release': new_release, 'new_release': new_release,
}) })