10520 remove Napalm code references (#11768)

* 10520 remove all Napalm code references

* 10520 remove lldp

* 10520 remove config, status - rebuild js

* 10520 re-add config parameters

* 10520 re-add serializer

* 10520 update docs
This commit is contained in:
Arthur Hanson 2023-02-24 12:38:50 -08:00 committed by GitHub
parent 927371b908
commit 36771e821c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 17 additions and 1095 deletions

View File

@ -1,5 +1,7 @@
# NAPALM Parameters
!!! **Note:** As of NetBox v3.5, NAPALM integration has been moved to a plugin and these configuration parameters are now deprecated.
## NAPALM_USERNAME
## NAPALM_PASSWORD

View File

@ -36,6 +36,8 @@ To learn more about this feature, check out the [webhooks documentation](../inte
To learn more about this feature, check out the [NAPALM documentation](../integrations/napalm.md).
As of NetBox v3.5, NAPALM integration has been moved to a plugin. Please see the [netbox_napalm_plugin](https://github.com/netbox-community/netbox-napalm) for installation instructions.
## Prometheus Metrics
NetBox includes a special `/metrics` view which exposes metrics for a [Prometheus](https://prometheus.io/) scraper, powered by the open source [django-prometheus](https://github.com/korfuri/django-prometheus) library. To learn more about this feature, check out the [Prometheus metrics documentation](../integrations/prometheus-metrics.md).

View File

@ -199,14 +199,6 @@ When you have finished modifying the configuration, remember to save the file.
All Python packages required by NetBox are listed in `requirements.txt` and will be installed automatically. NetBox also supports some optional packages. If desired, these packages must be listed in `local_requirements.txt` within the NetBox root directory.
### NAPALM
Integration with the [NAPALM automation](../integrations/napalm.md) library allows NetBox to fetch live data from devices and return it to a requester via its REST API. The `NAPALM_USERNAME` and `NAPALM_PASSWORD` configuration parameters define the credentials to be used when connecting to a device.
```no-highlight
sudo sh -c "echo 'napalm' >> /opt/netbox/local_requirements.txt"
```
### Remote File Storage
By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/system.md#storage_backend) in `configuration.py`.

View File

@ -1,74 +1,3 @@
# NAPALM
NetBox supports integration with the [NAPALM automation](https://github.com/napalm-automation/napalm) library. NAPALM allows NetBox to serve a proxy for operational data, fetching live data from network devices and returning it to a requester via its REST API. Note that NetBox does not store any NAPALM data locally.
The NetBox UI will display tabs for status, LLDP neighbors, and configuration under the device view if the following conditions are met:
* Device status is "Active"
* A primary IP has been assigned to the device
* A platform with a NAPALM driver has been assigned
* The authenticated user has the `dcim.napalm_read_device` permission
!!! note
To enable this integration, the NAPALM library must be installed. See [installation steps](../../installation/3-netbox/#napalm) for more information.
Below is an example REST API request and response:
```no-highlight
GET /api/dcim/devices/1/napalm/?method=get_environment
{
"get_environment": {
...
}
}
```
!!! note
To make NAPALM requests via the NetBox REST API, a NetBox user must have assigned a permission granting the `napalm_read` action for the device object type.
## Authentication
By default, the [`NAPALM_USERNAME`](../configuration/napalm.md#napalm_username) and [`NAPALM_PASSWORD`](../configuration/napalm.md#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers.
```
$ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \
-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json; indent=4" \
-H "X-NAPALM-Username: foo" \
-H "X-NAPALM-Password: bar"
```
## Method Support
The list of supported NAPALM methods depends on the [NAPALM driver](https://napalm.readthedocs.io/en/latest/support/index.html#general-support-matrix) configured for the platform of a device. Because there is no granular mechanism in place for limiting potentially disruptive requests, NetBox supports only read-only [get](https://napalm.readthedocs.io/en/latest/support/index.html#getters-support-matrix) methods.
## Multiple Methods
It is possible to request the output of multiple NAPALM methods in a single API request by passing multiple `method` parameters. For example:
```no-highlight
GET /api/dcim/devices/1/napalm/?method=get_ntp_servers&method=get_ntp_peers
{
"get_ntp_servers": {
...
},
"get_ntp_peers": {
...
}
}
```
## Optional Arguments
The behavior of NAPALM drivers can be adjusted according to the [optional arguments](https://napalm.readthedocs.io/en/latest/support/index.html#optional-arguments). NetBox exposes those arguments using headers prefixed with `X-NAPALM-`. For example, the SSH port is changed to 2222 in this API call:
```
$ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \
-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json; indent=4" \
-H "X-NAPALM-port: 2222"
```
As of NetBox v3.5, NAPALM integration has been moved to a plugin. Please see the [netbox_napalm_plugin](https://github.com/netbox-community/netbox-napalm) for installation instructions. **Note:** All previously entered NAPALM configuration data will be saved and automatically imported by the new plugin.

View File

@ -51,7 +51,6 @@ class DataSourceForm(NetBoxModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Determine the selected backend type
backend_type = get_field_value(self, 'type')
backend = registry['data_backends'].get(backend_type)

View File

@ -419,124 +419,6 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
return serializers.DeviceWithConfigContextSerializer
@swagger_auto_schema(
manual_parameters=[
Parameter(
name='method',
in_='query',
required=True,
type=openapi.TYPE_STRING
)
],
responses={'200': serializers.DeviceNAPALMSerializer}
)
@action(detail=True, url_path='napalm')
def napalm(self, request, pk):
"""
Execute a NAPALM method on a Device
"""
device = get_object_or_404(self.queryset, pk=pk)
if not device.primary_ip:
raise ServiceUnavailable("This device does not have a primary IP address configured.")
if device.platform is None:
raise ServiceUnavailable("No platform is configured for this device.")
if not device.platform.napalm_driver:
raise ServiceUnavailable(f"No NAPALM driver is configured for this device's platform: {device.platform}.")
# Check for primary IP address from NetBox object
if device.primary_ip:
host = str(device.primary_ip.address.ip)
else:
# Raise exception for no IP address and no Name if device.name does not exist
if not device.name:
raise ServiceUnavailable(
"This device does not have a primary IP address or device name to lookup configured."
)
try:
# Attempt to complete a DNS name resolution if no primary_ip is set
host = socket.gethostbyname(device.name)
except socket.gaierror:
# Name lookup failure
raise ServiceUnavailable(
f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or "
f"setup name resolution.")
# Check that NAPALM is installed
try:
import napalm
from napalm.base.exceptions import ModuleImportError
except ModuleNotFoundError as e:
if getattr(e, 'name') == 'napalm':
raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
raise e
# Validate the configured driver
try:
driver = napalm.get_network_driver(device.platform.napalm_driver)
except ModuleImportError:
raise ServiceUnavailable("NAPALM driver for platform {} not found: {}.".format(
device.platform, device.platform.napalm_driver
))
# Verify user permission
if not request.user.has_perm('dcim.napalm_read_device'):
return HttpResponseForbidden()
napalm_methods = request.GET.getlist('method')
response = {m: None for m in napalm_methods}
config = get_config()
username = config.NAPALM_USERNAME
password = config.NAPALM_PASSWORD
timeout = config.NAPALM_TIMEOUT
optional_args = config.NAPALM_ARGS.copy()
if device.platform.napalm_args is not None:
optional_args.update(device.platform.napalm_args)
# Update NAPALM parameters according to the request headers
for header in request.headers:
if header[:9].lower() != 'x-napalm-':
continue
key = header[9:]
if key.lower() == 'username':
username = request.headers[header]
elif key.lower() == 'password':
password = request.headers[header]
elif key:
optional_args[key.lower()] = request.headers[header]
# Connect to the device
d = driver(
hostname=host,
username=username,
password=password,
timeout=timeout,
optional_args=optional_args
)
try:
d.open()
except Exception as e:
raise ServiceUnavailable("Error connecting to the device at {}: {}".format(host, e))
# Validate and execute each specified NAPALM method
for method in napalm_methods:
if not hasattr(driver, method):
response[method] = {'error': 'Unknown NAPALM method'}
continue
if not method.startswith('get_'):
response[method] = {'error': 'Only get_* NAPALM methods are supported'}
continue
try:
response[method] = getattr(d, method)()
except NotImplementedError:
response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)}
except Exception as e:
response[method] = {'error': 'Method {} failed: {}'.format(method, e)}
d.close()
return Response(response)
class VirtualDeviceContextViewSet(NetBoxModelViewSet):
queryset = VirtualDeviceContext.objects.prefetch_related(

View File

@ -806,7 +806,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
class Meta:
model = Platform
fields = ['id', 'name', 'slug', 'napalm_driver', 'description']
fields = ['id', 'name', 'slug', 'description']
class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):

View File

@ -476,10 +476,6 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
queryset=Manufacturer.objects.all(),
required=False
)
napalm_driver = forms.CharField(
max_length=50,
required=False
)
config_template = DynamicModelChoiceField(
queryset=ConfigTemplate.objects.all(),
required=False
@ -491,9 +487,9 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
model = Platform
fieldsets = (
(None, ('manufacturer', 'config_template', 'napalm_driver', 'description')),
(None, ('manufacturer', 'config_template', 'description')),
)
nullable_fields = ('manufacturer', 'config_template', 'napalm_driver', 'description')
nullable_fields = ('manufacturer', 'config_template', 'description')
class DeviceBulkEditForm(NetBoxModelBulkEditForm):

View File

@ -342,7 +342,7 @@ class PlatformImportForm(NetBoxModelImportForm):
class Meta:
model = Platform
fields = (
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
)

View File

@ -451,19 +451,15 @@ class PlatformForm(NetBoxModelForm):
fieldsets = (
('Platform', (
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
)),
)
class Meta:
model = Platform
fields = [
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
]
widgets = {
'napalm_args': forms.Textarea(),
}
class DeviceForm(TenancyForm, NetBoxModelForm):

View File

@ -172,7 +172,6 @@ class PlatformIndex(SearchIndex):
fields = (
('name', 100),
('slug', 110),
('napalm_driver', 300),
('description', 500),
)

View File

@ -133,11 +133,11 @@ class PlatformTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = models.Platform
fields = (
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'napalm_driver',
'napalm_args', 'description', 'tags', 'actions', 'created', 'last_updated',
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'description',
'tags', 'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description',
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'description',
)

View File

@ -1469,9 +1469,9 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
Manufacturer.objects.bulk_create(manufacturers)
platforms = (
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], napalm_driver='driver-1', description='A'),
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], napalm_driver='driver-2', description='B'),
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], napalm_driver='driver-3', description='C'),
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], description='A'),
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], description='B'),
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='C'),
)
Platform.objects.bulk_create(platforms)
@ -1487,10 +1487,6 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_napalm_driver(self):
params = {'napalm_driver': ['driver-1', 'driver-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_manufacturer(self):
manufacturers = Manufacturer.objects.all()[:2]
params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]}

View File

@ -1591,8 +1591,6 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'name': 'Platform X',
'slug': 'platform-x',
'manufacturer': manufacturer.pk,
'napalm_driver': 'junos',
'napalm_args': None,
'description': 'A new platform',
'tags': [t.pk for t in tags],
}
@ -1612,7 +1610,6 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
)
cls.bulk_edit_data = {
'napalm_driver': 'ios',
'description': 'New description',
}

View File

@ -2080,71 +2080,6 @@ class DeviceBulkRenameView(generic.BulkRenameView):
table = tables.DeviceTable
#
# Device NAPALM views
#
class NAPALMViewTab(ViewTab):
def render(self, instance):
# Display NAPALM tabs only for devices which meet certain requirements
if not (
instance.status == 'active' and
instance.primary_ip and
instance.platform and
instance.platform.napalm_driver
):
return None
return super().render(instance)
@register_model_view(Device, 'status')
class DeviceStatusView(generic.ObjectView):
additional_permissions = ['dcim.napalm_read_device']
queryset = Device.objects.all()
template_name = 'dcim/device/status.html'
tab = NAPALMViewTab(
label=_('Status'),
permission='dcim.napalm_read_device',
weight=3000
)
@register_model_view(Device, 'lldp_neighbors', path='lldp-neighbors')
class DeviceLLDPNeighborsView(generic.ObjectView):
additional_permissions = ['dcim.napalm_read_device']
queryset = Device.objects.all()
template_name = 'dcim/device/lldp_neighbors.html'
tab = NAPALMViewTab(
label=_('LLDP Neighbors'),
permission='dcim.napalm_read_device',
weight=3100
)
def get_extra_context(self, request, instance):
interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related(
'_path'
).exclude(
type__in=NONCONNECTABLE_IFACE_TYPES
)
return {
'interfaces': interfaces,
}
@register_model_view(Device, 'config')
class DeviceConfigView(generic.ObjectView):
additional_permissions = ['dcim.napalm_read_device']
queryset = Device.objects.all()
template_name = 'dcim/device/config.html'
tab = NAPALMViewTab(
label=_('Config'),
permission='dcim.napalm_read_device',
weight=3200
)
#
# Modules
#

View File

@ -35,10 +35,6 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
'fields': ('CUSTOM_VALIDATORS',),
'classes': ('monospace',),
}),
('NAPALM', {
'fields': ('NAPALM_USERNAME', 'NAPALM_PASSWORD', 'NAPALM_TIMEOUT', 'NAPALM_ARGS'),
'classes': ('monospace',),
}),
('User Preferences', {
'fields': ('DEFAULT_USER_PREFERENCES',),
}),

View File

@ -40,9 +40,6 @@ async function bundleGraphIQL() {
async function bundleNetBox() {
const entryPoints = {
netbox: 'src/index.ts',
lldp: 'src/device/lldp.ts',
config: 'src/device/config.ts',
status: 'src/device/status.ts',
};
try {
const result = await esbuild.build({

Binary file not shown.

View File

@ -1,50 +0,0 @@
import { createToast } from '../bs';
import { apiGetBase, getNetboxData, hasError, toggleLoader } from '../util';
/**
* Initialize device config elements.
*/
function initConfig(): void {
toggleLoader('show');
const url = getNetboxData('data-object-url');
if (url !== null) {
apiGetBase<DeviceConfig>(url)
.then(data => {
if (hasError(data)) {
createToast('danger', 'Error Fetching Device Config', data.error).show();
console.error(data.error);
return;
} else if (hasError<Required<DeviceConfig['get_config']>>(data.get_config)) {
createToast('danger', 'Error Fetching Device Config', data.get_config.error).show();
console.error(data.get_config.error);
return;
} else {
const configTypes = ['running', 'startup', 'candidate'] as DeviceConfigType[];
for (const configType of configTypes) {
const element = document.getElementById(`${configType}_config`);
if (element !== null) {
const config = data.get_config[configType];
if (typeof config === 'string') {
// If the returned config is a string, set the element innerHTML as-is.
element.innerHTML = config;
} else {
// If the returned config is an object (dict), convert it to JSON.
element.innerHTML = JSON.stringify(data.get_config[configType], null, 2);
}
}
}
}
})
.finally(() => {
toggleLoader('hide');
});
}
}
if (document.readyState !== 'loading') {
initConfig();
} else {
document.addEventListener('DOMContentLoaded', initConfig);
}

View File

@ -1,143 +0,0 @@
import { createToast } from '../bs';
import { getNetboxData, apiGetBase, hasError, isTruthy, toggleLoader } from '../util';
// Match an interface name that begins with a capital letter and is followed by at least one other
// alphabetic character, and ends with a forward-slash-separated numeric sequence such as 0/1/2.
const CISCO_IOS_PATTERN = new RegExp(/^([A-Z][A-Za-z]+)[^0-9]*([0-9/]+)$/);
// Mapping of overrides to default Cisco IOS interface alias behavior (default behavior is to use
// the first two characters).
const CISCO_IOS_OVERRIDES = new Map<string, string>([
// Cisco IOS abbreviates 25G (TwentyFiveGigE) interfaces as 'Twe'.
['TwentyFiveGigE', 'Twe'],
]);
/**
* Get an attribute from a row's cell.
*
* @param row Interface row
* @param query CSS media query
* @param attr Cell attribute
*/
function getData(row: HTMLTableRowElement, query: string, attr: string): string | null {
return row.querySelector(query)?.getAttribute(attr) ?? null;
}
/**
* Get preconfigured alias for given interface. Primarily for matching long-form Cisco IOS
* interface names with short-form Cisco IOS interface names. For example, `GigabitEthernet0/1/2`
* would become `Gi0/1/2`.
*
* This should probably be replaced with something in the primary application (Django), such as
* a database field attached to given interface types. However, this is a temporary measure to
* replace the functionality of this one-liner:
*
* @see https://github.com/netbox-community/netbox/blob/9cc4992fad2fe04ef0211d998c517414e8871d8c/netbox/templates/dcim/device/lldp_neighbors.html#L69
*
* @param name Long-form/original interface name.
*/
function getInterfaceAlias(name: string | null): string | null {
if (name === null) {
return name;
}
if (name.match(CISCO_IOS_PATTERN)) {
// Extract the base name and numeric portions of the interface. For example, an input interface
// of `GigabitEthernet0/0/1` would result in an array of `['GigabitEthernet', '0/0/1']`.
const [base, numeric] = (name.match(CISCO_IOS_PATTERN) ?? []).slice(1, 3);
if (isTruthy(base) && isTruthy(numeric)) {
// Check the override map and use its value if the base name is present in the map.
// Otherwise, use the first two characters of the base name. For example,
// `GigabitEthernet0/0/1` would become `Gi0/0/1`, but `TwentyFiveGigE0/0/1` would become
// `Twe0/0/1`.
const aliasBase = CISCO_IOS_OVERRIDES.get(base) || base.slice(0, 2);
return `${aliasBase}${numeric}`;
}
}
return name;
}
/**
* Update row styles based on LLDP neighbor data.
*/
function updateRowStyle(data: LLDPNeighborDetail) {
for (const [fullIface, neighbors] of Object.entries(data.get_lldp_neighbors_detail)) {
const [iface] = fullIface.split('.');
const row = document.getElementById(iface) as Nullable<HTMLTableRowElement>;
if (row !== null) {
for (const neighbor of neighbors) {
const deviceCell = row.querySelector<HTMLTableCellElement>('td.device');
const interfaceCell = row.querySelector<HTMLTableCellElement>('td.interface');
const configuredDevice = getData(row, 'td.configured_device', 'data');
const configuredChassis = getData(row, 'td.configured_chassis', 'data-chassis');
const configuredIface = getData(row, 'td.configured_interface', 'data');
const interfaceAlias = getInterfaceAlias(configuredIface);
const remoteName = neighbor.remote_system_name ?? '';
const remotePort = neighbor.remote_port ?? '';
const [neighborDevice] = remoteName.split('.');
const [neighborIface] = remotePort.split('.');
if (deviceCell !== null) {
deviceCell.innerText = neighborDevice;
}
if (interfaceCell !== null) {
interfaceCell.innerText = neighborIface;
}
// Interface has an LLDP neighbor, but the neighbor is not configured in NetBox.
const nonConfiguredDevice = !isTruthy(configuredDevice) && isTruthy(neighborDevice);
// NetBox device or chassis matches LLDP neighbor.
const validNode =
configuredDevice === neighborDevice || configuredChassis === neighborDevice;
// NetBox configured interface matches LLDP neighbor interface.
const validInterface =
configuredIface === neighborIface || interfaceAlias === neighborIface;
if (nonConfiguredDevice) {
row.classList.add('info');
} else if (validNode && validInterface) {
row.classList.add('success');
} else {
row.classList.add('danger');
}
}
}
}
}
/**
* Initialize LLDP Neighbor fetching.
*/
function initLldpNeighbors() {
toggleLoader('show');
const url = getNetboxData('object-url');
if (url !== null) {
apiGetBase<LLDPNeighborDetail>(url)
.then(data => {
if (hasError(data)) {
createToast('danger', 'Error Retrieving LLDP Neighbor Information', data.error).show();
toggleLoader('hide');
return;
} else {
updateRowStyle(data);
}
return;
})
.finally(() => {
toggleLoader('hide');
});
}
}
if (document.readyState !== 'loading') {
initLldpNeighbors();
} else {
document.addEventListener('DOMContentLoaded', initLldpNeighbors);
}

View File

@ -1,379 +0,0 @@
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import duration from 'dayjs/plugin/duration';
import advancedFormat from 'dayjs/plugin/advancedFormat';
import { createToast } from '../bs';
import { apiGetBase, getNetboxData, hasError, toggleLoader, createElement, cToF } from '../util';
type Uptime = {
utc: string;
zoned: string | null;
duration: string;
};
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(advancedFormat);
dayjs.extend(duration);
const factKeys = [
'hostname',
'fqdn',
'vendor',
'model',
'serial_number',
'os_version',
] as (keyof DeviceFacts)[];
type DurationKeys = 'years' | 'months' | 'days' | 'hours' | 'minutes' | 'seconds';
const formatKeys = ['years', 'months', 'days', 'hours', 'minutes', 'seconds'] as DurationKeys[];
/**
* From a number of seconds that have elapsed since reboot, extract human-readable dates in the
* following formats:
* - Relative time since reboot (e.g. 1 month, 28 days, 1 hour, 30 seconds).
* - Time stamp in browser-relative timezone.
* - Time stamp in UTC.
* @param seconds Seconds since reboot.
*/
function getUptime(seconds: number): Uptime {
const relDate = new Date();
// Get the user's UTC offset, to determine if the user is in UTC or not.
const offset = relDate.getTimezoneOffset();
const relNow = dayjs(relDate);
// Get a dayjs object for the device reboot time (now - number of seconds).
const relThen = relNow.subtract(seconds, 'seconds');
// Get a human-readable version of the time in UTC.
const utc = relThen.tz('Etc/UTC').format('YYYY-MM-DD HH:MM:ss z');
// We only want to show the UTC time if the user is not already in UTC time.
let zoned = null;
if (offset !== 0) {
// If the user is not in UTC time, return a human-readable version in the user's timezone.
zoned = relThen.format('YYYY-MM-DD HH:MM:ss z');
}
// Get a dayjs duration object to create a human-readable relative time string.
const between = dayjs.duration(seconds, 'seconds');
// Array of all non-zero-value duration properties. For example, if duration.year() is 0, we
// don't care about it and shouldn't show it to the user.
let parts = [] as string[];
for (const key of formatKeys) {
// Get the property value. For example, duration.year(), duration.month(), etc.
const value = between[key]();
if (value === 1) {
// If the duration for this key is 1, drop the trailing 's'. For example, '1 seconds' would
// become '1 second'.
const label = key.replace(/s$/, '');
parts = [...parts, `${value} ${label}`];
} else if (value > 1) {
// If the duration for this key is more than one, add it to the array as-is.
parts = [...parts, `${value} ${key}`];
}
}
// Set the duration to something safe, so we don't show 'undefined' or an empty string to the user.
let duration = 'None';
if (parts.length > 0) {
// If the array actually has elements, reassign the duration to a human-readable version.
duration = parts.join(', ');
}
return { utc, zoned, duration };
}
/**
* After the `get_facts` result is received, parse its content and update HTML elements
* accordingly.
*
* @param facts NAPALM Device Facts
*/
function processFacts(facts: DeviceFacts): void {
for (const key of factKeys) {
if (key in facts) {
// Find the target element which should have its innerHTML/innerText set to a NAPALM value.
const element = document.getElementById(key);
if (element !== null) {
element.innerHTML = String(facts[key]);
}
}
}
const { uptime } = facts;
const { utc, zoned, duration } = getUptime(uptime);
// Find the duration (relative time) element and set its value.
const uptimeDurationElement = document.getElementById('uptime-duration');
if (uptimeDurationElement !== null) {
uptimeDurationElement.innerHTML = duration;
}
// Find the time stamp element and set its value.
const uptimeElement = document.getElementById('uptime');
if (uptimeElement !== null) {
if (zoned === null) {
// If the user is in UTC time, only add the UTC time stamp.
uptimeElement.innerHTML = utc;
} else {
// Otherwise, add both time stamps.
uptimeElement.innerHTML = [zoned, `<span class="fst-italic d-block">${utc}</span>`].join('');
}
}
}
/**
* Insert a title row before the next table row. The title row describes each environment key/value
* pair from the NAPALM response.
*
* @param next Next adjacent element. For example, if this is the CPU data, `next` would be the
* memory row.
* @param title1 Column 1 Title
* @param title2 Column 2 Title
*/
function insertTitleRow<E extends HTMLElement>(next: E, title1: string, title2: string): void {
// Create cell element that contains the key title.
const col1Title = createElement('th', { innerText: title1 }, ['border-end', 'text-end']);
// Create cell element that contains the value title.
const col2Title = createElement('th', { innerText: title2 }, ['border-start', 'text-start']);
// Create title row element with the two header cells as children.
const titleRow = createElement('tr', {}, [], [col1Title, col2Title]);
// Insert the entire row just before the beginning of the next row (i.e., at the end of this row).
next.insertAdjacentElement('beforebegin', titleRow);
}
/**
* Insert a "No Data" row, for when the NAPALM response doesn't contain this type of data.
*
* @param next Next adjacent element.For example, if this is the CPU data, `next` would be the
* memory row.
*/
function insertNoneRow<E extends Nullable<HTMLElement>>(next: E): void {
const none = createElement('td', { colSpan: '2', innerText: 'No Data' }, [
'text-muted',
'text-center',
]);
const titleRow = createElement('tr', {}, [], [none]);
if (next !== null) {
next.insertAdjacentElement('beforebegin', titleRow);
}
}
function getNext<E extends HTMLElement>(id: string): Nullable<E> {
const element = document.getElementById(id);
if (element !== null) {
return element.nextElementSibling as Nullable<E>;
}
return null;
}
/**
* Create & insert table rows for each CPU in the NAPALM response.
*
* @param cpu NAPALM CPU data.
*/
function processCpu(cpu: DeviceEnvironment['cpu']): void {
// Find the next adjacent element, so we can insert elements before it.
const next = getNext<HTMLTableRowElement>('status-cpu');
if (typeof cpu !== 'undefined') {
if (next !== null) {
insertTitleRow(next, 'Name', 'Usage');
for (const [core, data] of Object.entries(cpu)) {
const usage = data['%usage'];
const kCell = createElement('td', { innerText: core }, ['border-end', 'text-end']);
const vCell = createElement('td', { innerText: `${usage} %` }, [
'border-start',
'text-start',
]);
const row = createElement('tr', {}, [], [kCell, vCell]);
next.insertAdjacentElement('beforebegin', row);
}
}
} else {
insertNoneRow(next);
}
}
/**
* Create & insert table rows for the memory in the NAPALM response.
*
* @param mem NAPALM memory data.
*/
function processMemory(mem: DeviceEnvironment['memory']): void {
// Find the next adjacent element, so we can insert elements before it.
const next = getNext<HTMLTableRowElement>('status-memory');
if (typeof mem !== 'undefined') {
if (next !== null) {
insertTitleRow(next, 'Available', 'Used');
const { available_ram: avail, used_ram: used } = mem;
const aCell = createElement('td', { innerText: avail }, ['border-end', 'text-end']);
const uCell = createElement('td', { innerText: used }, ['border-start', 'text-start']);
const row = createElement('tr', {}, [], [aCell, uCell]);
next.insertAdjacentElement('beforebegin', row);
}
} else {
insertNoneRow(next);
}
}
/**
* Create & insert table rows for each temperature sensor in the NAPALM response.
*
* @param temp NAPALM temperature data.
*/
function processTemp(temp: DeviceEnvironment['temperature']): void {
// Find the next adjacent element, so we can insert elements before it.
const next = getNext<HTMLTableRowElement>('status-temperature');
if (typeof temp !== 'undefined') {
if (next !== null) {
insertTitleRow(next, 'Sensor', 'Value');
for (const [sensor, data] of Object.entries(temp)) {
const tempC = data.temperature;
const tempF = cToF(tempC);
const innerHTML = `${tempC} °C <span class="ms-1 text-muted small">${tempF} °F</span>`;
const status = data.is_alert ? 'warning' : data.is_critical ? 'danger' : 'success';
const kCell = createElement('td', { innerText: sensor }, ['border-end', 'text-end']);
const vCell = createElement('td', { innerHTML }, ['border-start', 'text-start']);
const row = createElement('tr', {}, [`table-${status}`], [kCell, vCell]);
next.insertAdjacentElement('beforebegin', row);
}
}
} else {
insertNoneRow(next);
}
}
/**
* Create & insert table rows for each fan in the NAPALM response.
*
* @param fans NAPALM fan data.
*/
function processFans(fans: DeviceEnvironment['fans']): void {
// Find the next adjacent element, so we can insert elements before it.
const next = getNext<HTMLTableRowElement>('status-fans');
if (typeof fans !== 'undefined') {
if (next !== null) {
insertTitleRow(next, 'Fan', 'Status');
for (const [fan, data] of Object.entries(fans)) {
const { status } = data;
const goodIcon = createElement('i', {}, ['mdi', 'mdi-check-bold', 'text-success']);
const badIcon = createElement('i', {}, ['mdi', 'mdi-close', 'text-warning']);
const kCell = createElement('td', { innerText: fan }, ['border-end', 'text-end']);
const vCell = createElement(
'td',
{},
['border-start', 'text-start'],
[status ? goodIcon : badIcon],
);
const row = createElement(
'tr',
{},
[`table-${status ? 'success' : 'warning'}`],
[kCell, vCell],
);
next.insertAdjacentElement('beforebegin', row);
}
}
} else {
insertNoneRow(next);
}
}
/**
* Create & insert table rows for each PSU in the NAPALM response.
*
* @param power NAPALM power data.
*/
function processPower(power: DeviceEnvironment['power']): void {
// Find the next adjacent element, so we can insert elements before it.
const next = getNext<HTMLTableRowElement>('status-power');
if (typeof power !== 'undefined') {
if (next !== null) {
insertTitleRow(next, 'PSU', 'Status');
for (const [psu, data] of Object.entries(power)) {
const { status } = data;
const goodIcon = createElement('i', {}, ['mdi', 'mdi-check-bold', 'text-success']);
const badIcon = createElement('i', {}, ['mdi', 'mdi-close', 'text-warning']);
const kCell = createElement('td', { innerText: psu }, ['border-end', 'text-end']);
const vCell = createElement(
'td',
{},
['border-start', 'text-start'],
[status ? goodIcon : badIcon],
);
const row = createElement(
'tr',
{},
[`table-${status ? 'success' : 'warning'}`],
[kCell, vCell],
);
next.insertAdjacentElement('beforebegin', row);
}
}
} else {
insertNoneRow(next);
}
}
/**
* After the `get_environment` result is received, parse its content and update HTML elements
* accordingly.
*
* @param env NAPALM Device Environment
*/
function processEnvironment(env: DeviceEnvironment): void {
const { cpu, memory, temperature, fans, power } = env;
processCpu(cpu);
processMemory(memory);
processTemp(temperature);
processFans(fans);
processPower(power);
}
/**
* Initialize NAPALM device status handlers.
*/
function initStatus(): void {
// Show loading state for both Facts & Environment cards.
toggleLoader('show');
const url = getNetboxData('data-object-url');
if (url !== null) {
apiGetBase<DeviceStatus>(url)
.then(data => {
if (hasError(data)) {
// If the API returns an error, show it to the user.
createToast('danger', 'Error Fetching Device Status', data.error).show();
} else {
if (!hasError(data.get_facts)) {
processFacts(data.get_facts);
} else {
// If the device facts data contains an error, show it to the user.
createToast('danger', 'Error Fetching Device Facts', data.get_facts.error).show();
}
if (!hasError(data.get_environment)) {
processEnvironment(data.get_environment);
} else {
// If the device environment data contains an error, show it to the user.
createToast(
'danger',
'Error Fetching Device Environment Data',
data.get_environment.error,
).show();
}
}
return;
})
.finally(() => toggleLoader('hide'));
} else {
toggleLoader('hide');
}
}
if (document.readyState !== 'loading') {
initStatus();
} else {
document.addEventListener('DOMContentLoaded', initStatus);
}

View File

@ -397,16 +397,6 @@ export function createElement<
return element as HTMLElementTagNameMap[T];
}
/**
* Convert Celsius to Fahrenheit, for NAPALM temperature sensors.
*
* @param celsius Degrees in Celsius.
* @returns Degrees in Fahrenheit.
*/
export function cToF(celsius: number): number {
return Math.round((celsius * (9 / 5) + 32 + Number.EPSILON) * 10) / 10;
}
/**
* Deduplicate an array of objects based on the value of a property.
*

View File

@ -1,45 +0,0 @@
{% extends 'dcim/device/base.html' %}
{% load static %}
{% block title %}{{ object }} - Config{% endblock %}
{% block head %}
<script type="text/javascript" src="{% static 'config.js' %}" onerror="window.location='{% url 'media_failure' %}?filename=config.js'"></script>
{% endblock %}
{% block content %}
<div class="row">
<div class="col">
<div class="card">
<div class="card-overlay">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<h5 class="card-header">Device Configuration</h5>
<div class="card-body">
<ul class="nav nav-tabs" role="tablist">
<li role="presentation"><a class="nav-link active" href="#running" aria-controls="running" role="tab" data-bs-toggle="tab">Running</a></li>
<li role="presentation"><a class="nav-link" href="#startup" aria-controls="startup" role="tab" data-bs-toggle="tab">Startup</a></li>
<li role="presentation"><a class="nav-link" href="#candidate" aria-controls="candidate" role="tab" data-bs-toggle="tab">Candidate</a></li>
</ul>
<div class="tab-content p-3">
<div role="tabpanel" class="tab-pane active" id="running">
<pre id="running_config"></pre>
</div>
<div role="tabpanel" class="tab-pane" id="startup">
<pre id="startup_config"></pre>
</div>
<div role="tabpanel" class="tab-pane" id="candidate">
<pre id="candidate_config"></pre>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block data %}
<span data-object-url="{% url 'dcim-api:device-napalm' pk=object.pk %}?method=get_config"></span>
{% endblock %}

View File

@ -1,66 +0,0 @@
{% extends 'dcim/device/base.html' %}
{% load static %}
{% block title %}{{ object }} - LLDP Neighbors{% endblock %}
{% block head %}
<script type="text/javascript" src="{% static 'lldp.js' %}" onerror="window.location='{% url 'media_failure' %}?filename=lldp.js'"></script>
{% endblock %}
{% block content %}
<div class="card">
<div class="card-overlay">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div class="card-header">
<h5 class="d-inline">LLDP Neighbors</h5>
</div>
<div class="card-body">
<table class="table table-hover">
<thead>
<tr>
<th>Interface</th>
<th>Configured Device</th>
<th>Configured Interface</th>
<th>LLDP Device</th>
<th>LLDP Interface</th>
</tr>
</thead>
<tbody>
{% for iface in interfaces %}
<tr id="{{ iface.name }}">
<td>{{ iface }}</td>
{% with peer=iface.connected_endpoints.0 %}
{% if peer.device %}
<td class="configured_device" data="{{ peer.device.name }}" data-chassis="{{ peer.device.virtual_chassis.name }}">
<a href="{% url 'dcim:device' pk=peer.device.pk %}">{{ peer.device }}</a>
</td>
<td class="configured_interface" data="{{ peer.name }}">
<span title="{{ peer.get_type_display }}">{{ peer }}</span>
</td>
{% elif peer.circuit %}
{% with circuit=peer.circuit %}
<td colspan="2">
<i class="mdi mdi-lightning-bolt" title="Circuit"></i>
<a href="{{ circuit.get_absolute_url }}">{{ circuit.provider }} {{ circuit }}</a>
</td>
{% endwith %}
{% else %}
<td class="text-muted" colspan="2">None</td>
{% endif %}
{% endwith %}
<td class="device"></td>
<td class="interface"></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block data %}
<span data-object-url="{% url 'dcim-api:device-napalm' pk=object.pk %}?method=get_lldp_neighbors_detail"></span>
{% endblock %}

View File

@ -1,93 +0,0 @@
{% extends 'dcim/device/base.html' %}
{% load static %}
{% block title %}{{ object }} - Status{% endblock %}
{% block head %}
<script type="text/javascript" src="{% static 'status.js' %}" onerror="window.location='{% url 'media_failure' %}?filename=status.js'"></script>
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<div class="card-overlay">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<h5 class="card-header">Device Facts</h5>
<div class="card-body">
<table class="table">
<tr>
<th scope="row">Hostname</th>
<td id="hostname"></td>
</tr>
<tr>
<th scope="row">FQDN</th>
<td id="fqdn"></td>
</tr>
<tr>
<th scope="row">Vendor</th>
<td id="vendor"></td>
</tr>
<tr>
<th scope="row">Model</th>
<td id="model"></td>
</tr>
<tr>
<th scope="row">Serial Number</th>
<td id="serial_number" class="text-monospace"></td>
</tr>
<tr>
<th scope="row">OS Version</th>
<td id="os_version"></td>
</tr>
<tr class="align-middle">
<th scope="row">Uptime</th>
<td>
<div id="uptime-duration"></div>
<div id="uptime" class="small text-muted"></div>
</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col col-md-6">
<div class="card">
<div class="card-overlay">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<h5 class="card-header">Environment</h5>
<div class="card-body">
<table class="table">
<tr id="status-cpu">
<th colspan="2"><i class="mdi mdi-gauge"></i> CPU</th>
</tr>
<tr id="status-memory">
<th colspan="2"><i class="mdi mdi-chip"></i> Memory</th>
</tr>
<tr id="status-temperature">
<th colspan="2"><i class="mdi mdi-thermometer"></i> Temperature</th>
</tr>
<tr id="status-fans">
<th colspan="2"><i class="mdi mdi-fan"></i> Fans</th>
</tr>
<tr id="status-power">
<th colspan="2"><i class="mdi mdi-power"></i> Power</th>
</tr>
<tr class="napalm-table-placeholder d-none invisible">
</tr>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block data %}
<span data-object-url="{% url 'dcim-api:device-napalm' pk=object.pk %}?method=get_facts&method=get_environment"></span>
{% endblock %}

View File

@ -43,20 +43,10 @@
<th scope="row">Config Template</th>
<td>{{ object.config_template|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">NAPALM Driver</th>
<td>{{ object.napalm_driver|placeholder }}</td>
</tr>
</table>
</div>
</div>
{% include 'inc/panels/tags.html' %}
<div class="card">
<h5 class="card-header">NAPALM Arguments</h5>
<div class="card-body">
<pre>{{ object.napalm_args|json }}</pre>
</div>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-md-6">