diff --git a/netbox/core/api/serializers_/jobs.py b/netbox/core/api/serializers_/jobs.py index 306287e88..dd0dd1245 100644 --- a/netbox/core/api/serializers_/jobs.py +++ b/netbox/core/api/serializers_/jobs.py @@ -23,6 +23,6 @@ class JobSerializer(BaseModelSerializer): model = Job fields = [ 'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', - 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', + 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'log_entries', ] brief_fields = ('url', 'created', 'completed', 'user', 'status') diff --git a/netbox/core/dataclasses.py b/netbox/core/dataclasses.py new file mode 100644 index 000000000..863ebd0a8 --- /dev/null +++ b/netbox/core/dataclasses.py @@ -0,0 +1,19 @@ +import logging + +from dataclasses import dataclass, field +from datetime import datetime + +__all__ = ( + 'JobLogEntry', +) + + +@dataclass +class JobLogEntry: + level: str + message: str + timestamp: datetime = field(default_factory=datetime.now) + + @classmethod + def from_logrecord(cls, record: logging.LogRecord): + return cls(record.levelname.lower(), record.msg) diff --git a/netbox/core/migrations/0016_job_log_entries.py b/netbox/core/migrations/0016_job_log_entries.py new file mode 100644 index 000000000..26d2ba24d --- /dev/null +++ b/netbox/core/migrations/0016_job_log_entries.py @@ -0,0 +1,25 @@ +import django.contrib.postgres.fields +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0015_remove_redundant_indexes'), + ] + + operations = [ + migrations.AddField( + model_name='job', + name='log_entries', + field=django.contrib.postgres.fields.ArrayField( + base_field=models.JSONField( + encoder=django.core.serializers.json.DjangoJSONEncoder + ), + blank=True, + default=list, + size=None + ), + ), + ] diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 779e767b6..d6b4d58e8 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -1,9 +1,12 @@ +import logging import uuid +from dataclasses import asdict from functools import partial import django_rq from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.core.serializers.json import DjangoJSONEncoder from django.core.validators import MinValueValidator @@ -14,6 +17,7 @@ from django.utils.translation import gettext as _ from rq.exceptions import InvalidJobOperation from core.choices import JobStatusChoices +from core.dataclasses import JobLogEntry from core.models import ObjectType from core.signals import job_end, job_start from utilities.querysets import RestrictedQuerySet @@ -104,6 +108,14 @@ class Job(models.Model): verbose_name=_('job ID'), unique=True ) + log_entries = ArrayField( + base_field=models.JSONField( + encoder=DjangoJSONEncoder, + # TODO: Specify a decoder to handle ISO 8601 timestamps + ), + blank=True, + default=list, + ) objects = RestrictedQuerySet.as_manager() @@ -271,3 +283,10 @@ class Job(models.Model): transaction.on_commit(callback) return job + + def log(self, record: logging.LogRecord): + """ + Record a Python LogRecord in the job's log. + """ + entry = JobLogEntry.from_logrecord(record) + self.log_entries.append(asdict(entry)) diff --git a/netbox/core/tables/jobs.py b/netbox/core/tables/jobs.py index ac27224b3..2387a1864 100644 --- a/netbox/core/tables/jobs.py +++ b/netbox/core/tables/jobs.py @@ -1,7 +1,7 @@ import django_tables2 as tables from django.utils.translation import gettext_lazy as _ -from netbox.tables import NetBoxTable, columns +from netbox.tables import BaseTable, NetBoxTable, columns from ..models import Job @@ -40,6 +40,9 @@ class JobTable(NetBoxTable): completed = columns.DateTimeColumn( verbose_name=_('Completed'), ) + log_entries = tables.Column( + verbose_name=_('Log Entries'), + ) actions = columns.ActionsColumn( actions=('delete',) ) @@ -53,3 +56,22 @@ class JobTable(NetBoxTable): default_columns = ( 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user', ) + + def render_log_entries(self, value): + return len(value) + + +class JobLogEntryTable(BaseTable): + timestamp = tables.Column( + verbose_name=_('Time'), + ) + level = tables.Column( + verbose_name=_('Level'), + ) + message = tables.Column( + verbose_name=_('Message'), + ) + + class Meta(BaseTable.Meta): + empty_text = _('No log entries') + fields = ('timestamp', 'level', 'message') diff --git a/netbox/core/views.py b/netbox/core/views.py index 5729e5f2c..8e84ce6d3 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -32,13 +32,13 @@ from utilities.forms import ConfirmationForm from utilities.htmx import htmx_partial from utilities.json import ConfigJSONEncoder from utilities.query import count_related -from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view +from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, ViewTab, register_model_view from . import filtersets, forms, tables from .choices import DataSourceStatusChoices from .jobs import SyncDataSourceJob from .models import * from .plugins import get_catalog_plugins, get_local_plugins -from .tables import CatalogPluginTable, PluginVersionTable +from .tables import CatalogPluginTable, JobLogEntryTable, PluginVersionTable # @@ -184,6 +184,25 @@ class JobView(generic.ObjectView): actions = (DeleteObject,) +@register_model_view(Job, 'log') +class JobLogView(generic.ObjectView): + queryset = Job.objects.all() + actions = (DeleteObject,) + template_name = 'core/job_log.html' + tab = ViewTab( + label=_('Log'), + badge=lambda obj: len(obj.log_entries), + weight=500, + ) + + def get_extra_context(self, request, instance): + table = JobLogEntryTable(instance.log_entries) + table.configure(request) + return { + 'table': table, + } + + @register_model_view(Job, 'delete') class JobDeleteView(generic.ObjectDeleteView): queryset = Job.objects.defer('data') diff --git a/netbox/extras/jobs.py b/netbox/extras/jobs.py index 733654198..c4a1b3b26 100644 --- a/netbox/extras/jobs.py +++ b/netbox/extras/jobs.py @@ -90,7 +90,10 @@ class ScriptJob(JobRunner): request: The WSGI request associated with this execution (if any) commit: Passed through to Script.run() """ - script = ScriptModel.objects.get(pk=self.job.object_id).python_class() + script_model = ScriptModel.objects.get(pk=self.job.object_id) + self.logger.debug(f"Found ScriptModel ID {script_model.pk}") + script = script_model.python_class() + self.logger.debug(f"Loaded script {script.full_name}") # Add files to form data if request: @@ -100,6 +103,7 @@ class ScriptJob(JobRunner): # Add the current request as a property of the script script.request = request + self.logger.debug(f"Request ID: {request.id}") # Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process # change logging, event rules, etc. diff --git a/netbox/netbox/jobs.py b/netbox/netbox/jobs.py index 3af3af554..f54f61f25 100644 --- a/netbox/netbox/jobs.py +++ b/netbox/netbox/jobs.py @@ -34,6 +34,17 @@ def system_job(interval): return _wrapper +class JobLogHandler(logging.Handler): + + def __init__(self, job, *args, **kwargs): + super().__init__(*args, **kwargs) + self.job = job + + def emit(self, record): + # Enter the record in the log of the associated Job + self.job.log(record) + + class JobRunner(ABC): """ Background Job helper class. @@ -52,6 +63,11 @@ class JobRunner(ABC): """ self.job = job + # Initiate the system logger + self.logger = logging.getLogger(f"netbox.jobs.{self.__class__.__name__}") + self.logger.setLevel(logging.DEBUG) + self.logger.addHandler(JobLogHandler(job)) + @classproperty def name(cls): return getattr(cls.Meta, 'name', cls.__name__) diff --git a/netbox/templates/core/job_log.html b/netbox/templates/core/job_log.html new file mode 100644 index 000000000..18a01fc79 --- /dev/null +++ b/netbox/templates/core/job_log.html @@ -0,0 +1,34 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} +{% load perms %} +{% load i18n %} +{% load render_table from django_tables2 %} + +{% block breadcrumbs %} + {{ block.super }} + {% if object.object %} +