mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-16 04:02:52 -06:00
Initial work on #19816
This commit is contained in:
parent
a1cd81ff35
commit
ae3de95dce
@ -23,6 +23,6 @@ class JobSerializer(BaseModelSerializer):
|
|||||||
model = Job
|
model = Job
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled',
|
'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')
|
brief_fields = ('url', 'created', 'completed', 'user', 'status')
|
||||||
|
19
netbox/core/dataclasses.py
Normal file
19
netbox/core/dataclasses.py
Normal file
@ -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)
|
25
netbox/core/migrations/0016_job_log_entries.py
Normal file
25
netbox/core/migrations/0016_job_log_entries.py
Normal file
@ -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
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -1,9 +1,12 @@
|
|||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
from dataclasses import asdict
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
||||||
import django_rq
|
import django_rq
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
@ -14,6 +17,7 @@ from django.utils.translation import gettext as _
|
|||||||
from rq.exceptions import InvalidJobOperation
|
from rq.exceptions import InvalidJobOperation
|
||||||
|
|
||||||
from core.choices import JobStatusChoices
|
from core.choices import JobStatusChoices
|
||||||
|
from core.dataclasses import JobLogEntry
|
||||||
from core.models import ObjectType
|
from core.models import ObjectType
|
||||||
from core.signals import job_end, job_start
|
from core.signals import job_end, job_start
|
||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
@ -104,6 +108,14 @@ class Job(models.Model):
|
|||||||
verbose_name=_('job ID'),
|
verbose_name=_('job ID'),
|
||||||
unique=True
|
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()
|
objects = RestrictedQuerySet.as_manager()
|
||||||
|
|
||||||
@ -271,3 +283,10 @@ class Job(models.Model):
|
|||||||
transaction.on_commit(callback)
|
transaction.on_commit(callback)
|
||||||
|
|
||||||
return job
|
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))
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django.utils.translation import gettext_lazy as _
|
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
|
from ..models import Job
|
||||||
|
|
||||||
|
|
||||||
@ -40,6 +40,9 @@ class JobTable(NetBoxTable):
|
|||||||
completed = columns.DateTimeColumn(
|
completed = columns.DateTimeColumn(
|
||||||
verbose_name=_('Completed'),
|
verbose_name=_('Completed'),
|
||||||
)
|
)
|
||||||
|
log_entries = tables.Column(
|
||||||
|
verbose_name=_('Log Entries'),
|
||||||
|
)
|
||||||
actions = columns.ActionsColumn(
|
actions = columns.ActionsColumn(
|
||||||
actions=('delete',)
|
actions=('delete',)
|
||||||
)
|
)
|
||||||
@ -53,3 +56,22 @@ class JobTable(NetBoxTable):
|
|||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
|
'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')
|
||||||
|
@ -32,13 +32,13 @@ from utilities.forms import ConfirmationForm
|
|||||||
from utilities.htmx import htmx_partial
|
from utilities.htmx import htmx_partial
|
||||||
from utilities.json import ConfigJSONEncoder
|
from utilities.json import ConfigJSONEncoder
|
||||||
from utilities.query import count_related
|
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 . import filtersets, forms, tables
|
||||||
from .choices import DataSourceStatusChoices
|
from .choices import DataSourceStatusChoices
|
||||||
from .jobs import SyncDataSourceJob
|
from .jobs import SyncDataSourceJob
|
||||||
from .models import *
|
from .models import *
|
||||||
from .plugins import get_catalog_plugins, get_local_plugins
|
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,)
|
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')
|
@register_model_view(Job, 'delete')
|
||||||
class JobDeleteView(generic.ObjectDeleteView):
|
class JobDeleteView(generic.ObjectDeleteView):
|
||||||
queryset = Job.objects.defer('data')
|
queryset = Job.objects.defer('data')
|
||||||
|
@ -90,7 +90,10 @@ class ScriptJob(JobRunner):
|
|||||||
request: The WSGI request associated with this execution (if any)
|
request: The WSGI request associated with this execution (if any)
|
||||||
commit: Passed through to Script.run()
|
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
|
# Add files to form data
|
||||||
if request:
|
if request:
|
||||||
@ -100,6 +103,7 @@ class ScriptJob(JobRunner):
|
|||||||
|
|
||||||
# Add the current request as a property of the script
|
# Add the current request as a property of the script
|
||||||
script.request = request
|
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
|
# Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
|
||||||
# change logging, event rules, etc.
|
# change logging, event rules, etc.
|
||||||
|
@ -34,6 +34,17 @@ def system_job(interval):
|
|||||||
return _wrapper
|
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):
|
class JobRunner(ABC):
|
||||||
"""
|
"""
|
||||||
Background Job helper class.
|
Background Job helper class.
|
||||||
@ -52,6 +63,11 @@ class JobRunner(ABC):
|
|||||||
"""
|
"""
|
||||||
self.job = job
|
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
|
@classproperty
|
||||||
def name(cls):
|
def name(cls):
|
||||||
return getattr(cls.Meta, 'name', cls.__name__)
|
return getattr(cls.Meta, 'name', cls.__name__)
|
||||||
|
34
netbox/templates/core/job_log.html
Normal file
34
netbox/templates/core/job_log.html
Normal file
@ -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 %}
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a href="{% url 'core:job_list' %}?object_type={{ object.object_type_id }}">{{ object.object|meta:"verbose_name_plural"|bettertitle }}</a>
|
||||||
|
</li>
|
||||||
|
{% with parent_jobs_viewname=object.object|viewname:"jobs" %}
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a href="{% url parent_jobs_viewname pk=object.object.pk %}">{{ object.object }}</a>
|
||||||
|
</li>
|
||||||
|
{% endwith %}
|
||||||
|
{% else %}
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a href="{% url 'core:job_list' %}?name={{ object.name|urlencode }}">{{ object.name }}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock breadcrumbs %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
{% render_table table %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
Loading…
Reference in New Issue
Block a user