migrate napalm device status to typescript

This commit is contained in:
checktheroads 2021-04-21 10:19:13 -07:00
parent 4827cd24d8
commit 08b955f8b6
17 changed files with 536 additions and 93 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
netbox/project-static/dist/status.js vendored Normal file

Binary file not shown.

BIN
netbox/project-static/dist/status.js.map vendored Normal file

Binary file not shown.

View File

@ -5,7 +5,7 @@
"license": "Apache2",
"scripts": {
"bundle:css": "parcel build --public-url /static -o netbox.css main.scss && parcel build --public-url /static -o rack_elevation.css rack_elevation.scss",
"bundle:js": "parcel build --public-url /static -o netbox.js src/index.ts && parcel build --public-url /static -o jobs.js src/jobs.ts && parcel build --public-url /static -o lldp.js src/device/lldp.ts && parcel build --public-url /static -o config.js src/device/config.ts",
"bundle:js": "parcel build --public-url /static -o netbox.js src/index.ts && parcel build --public-url /static -o jobs.js src/jobs.ts && parcel build --public-url /static -o lldp.js src/device/lldp.ts && parcel build --public-url /static -o config.js src/device/config.ts && parcel build --public-url /static -o status.js src/device/status.ts",
"bundle": "yarn bundle:css && yarn bundle:js"
},
"dependencies": {
@ -16,6 +16,7 @@
"clipboard": "2.0.6",
"color2k": "^1.2.4",
"cookie": "^0.4.1",
"dayjs": "^1.10.4",
"flatpickr": "4.6.3",
"jquery": "3.5.1",
"jquery-ui": "1.12.1",

View File

@ -13,6 +13,7 @@ function initConfig() {
.then(data => {
if (hasError(data)) {
createToast('danger', 'Error Fetching Device Config', data.error).show();
return;
} else {
const configTypes = [
'running',

View File

@ -0,0 +1,379 @@
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) {
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) {
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']) {
// 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']) {
// 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']) {
// 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']) {
// 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']) {
// 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) {
const { cpu, memory, temperature, fans, power } = env;
processCpu(cpu);
processMemory(memory);
processTemp(temperature);
processFans(fans);
processPower(power);
}
/**
* Initialize NAPALM device status handlers.
*/
function initStatus() {
// 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

@ -121,6 +121,47 @@ type DeviceConfig = {
};
};
type DeviceEnvironment = {
cpu?: {
[core: string]: { '%usage': number };
};
memory?: {
available_ram: number;
used_ram: number;
};
power?: {
[psu: string]: { capacity: number; output: number; status: boolean };
};
temperature?: {
[sensor: string]: {
is_alert: boolean;
is_critical: boolean;
temperature: number;
};
};
fans?: {
[fan: string]: {
status: boolean;
};
};
};
type DeviceFacts = {
fqdn: string;
hostname: string;
interface_list: string[];
model: string;
os_version: string;
serial_number: string;
uptime: number;
vendor: string;
};
type DeviceStatus = {
get_environment: DeviceEnvironment | ErrorBase;
get_facts: DeviceFacts | ErrorBase;
};
interface ObjectWithGroup extends APIObjectBase {
group: Nullable<APIReference>;
}

View File

@ -5,15 +5,15 @@ type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
type ReqData = URLSearchParams | Dict | undefined | unknown;
type SelectedOption = { name: string; options: string[] };
// interface TableValue {
// row: {
// element: HTMLTableRowElement;
// };
// cell: {
// element: HTMLTableCellElement;
// value: string;
// };
// }
type HTMLElementProperties<E extends HTMLElement> =
| {
[k in keyof E]: E[k];
}
| {};
type InferredProps<T extends keyof HTMLElementTagNameMap> = HTMLElementProperties<
HTMLElementTagNameMap[T]
>;
export function isApiError(data: Record<string, unknown>): data is APIError {
return 'error' in data && 'exception' in data;
@ -170,6 +170,12 @@ export function scrollTo(element: Element, offset: number = 0): void {
return;
}
/**
* Iterate through a select element's options and return an array of options that are selected.
*
* @param base Select element.
* @returns Array of selected options.
*/
export function getSelectedOptions<E extends HTMLElement>(base: E): SelectedOption[] {
let selected = [] as SelectedOption[];
for (const element of base.querySelectorAll<HTMLSelectElement>('select')) {
@ -186,6 +192,16 @@ export function getSelectedOptions<E extends HTMLElement>(base: E): SelectedOpti
return selected;
}
/**
* Get data that can only be accessed via Django context, and is thus already rendered in the HTML
* template.
*
* @see Templates requiring Django context data have a `{% block data %}` block.
*
* @param key Property name, which must exist on the HTML element. If not already prefixed with
* `data-`, `data-` will be prepended to the property.
* @returns Value if it exists, `null` if not.
*/
export function getNetboxData(key: string): string | null {
if (!key.startsWith('data-')) {
key = `data-${key}`;
@ -203,12 +219,11 @@ export function getNetboxData(key: string): string | null {
* Toggle visibility of card loader.
*/
export function toggleLoader(action: 'show' | 'hide') {
const spinnerContainer = document.querySelector('div.card-overlay');
if (spinnerContainer !== null) {
for (const element of getElements<HTMLDivElement>('div.card-overlay')) {
if (action === 'show') {
spinnerContainer.classList.remove('d-none');
element.classList.remove('d-none');
} else {
spinnerContainer.classList.add('d-none');
element.classList.add('d-none');
}
}
}
@ -250,3 +265,51 @@ export function findFirstAdjacent<R extends HTMLElement, B extends Element = Ele
}
return match(base);
}
/**
* Helper for creating HTML elements.
*
* @param tag HTML element type.
* @param properties Properties/attributes to apply to the element.
* @param classes CSS classes to apply to the element.
* @param children Child elements.
*/
export function createElement<
T extends keyof HTMLElementTagNameMap,
C extends HTMLElement = HTMLElement
>(
tag: T,
properties: InferredProps<T>,
classes: string[],
children: C[] = [],
): HTMLElementTagNameMap[T] {
// Create the base element.
const element = document.createElement<T>(tag);
for (const k of Object.keys(properties)) {
// Add each property to the element.
const key = k as keyof HTMLElementProperties<HTMLElementTagNameMap[T]>;
const value = properties[key];
if (key in element) {
element[key] = value;
}
}
// Add each CSS class to the element's class list.
element.classList.add(...classes);
for (const child of children) {
// Add each child element to the base element.
element.appendChild(child);
}
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 celsius * (9 / 5) + 32;
}

View File

@ -2688,6 +2688,11 @@ data-urls@^1.1.0:
whatwg-mimetype "^2.2.0"
whatwg-url "^7.0.0"
dayjs@^1.10.4:
version "1.10.4"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.4.tgz#8e544a9b8683f61783f570980a8a80eaf54ab1e2"
integrity sha512-RI/Hh4kqRc1UKLOAf/T5zdMMX5DQIlDxwUe3wSyMMnEbGunnpENCdbUgM+dW7kXidZqCttBrmw7BhN4TMddkCw==
de-indent@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d"

View File

@ -1,13 +1,21 @@
{% extends 'dcim/device/base.html' %}
{% load static %}
{% block title %}{{ device }} - Status{% endblock %}
{% 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 %}
{% include 'inc/ajax_loader.html' %}
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-overlay d-none">
<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">
@ -29,15 +37,20 @@
</tr>
<tr>
<th scope="row">Serial Number</th>
<td id="serial_number"></td>
<td>
<code id="serial_number"></code>
</td>
</tr>
<tr>
<th scope="row">OS Version</th>
<td id="os_version"></td>
</tr>
<tr>
<tr class="align-middle">
<th scope="row">Uptime</th>
<td id="uptime"></td>
<td>
<div id="uptime-duration"></div>
<div id="uptime" class="small text-muted"></div>
</td>
</tr>
</table>
</div>
@ -45,24 +58,31 @@
</div>
<div class="col-md-6">
<div class="card">
<div class="card-overlay d-none">
<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="cpu">
<tr id="status-cpu" class="bg-light">
<th colspan="2"><i class="mdi mdi-gauge"></i> CPU</th>
</tr>
<tr id="memory">
<tr id="status-memory" class="bg-light">
<th colspan="2"><i class="mdi mdi-chip"></i> Memory</th>
</tr>
<tr id="temperature">
<tr id="status-temperature" class="bg-light">
<th colspan="2"><i class="mdi mdi-thermometer"></i> Temperature</th>
</tr>
<tr id="fans">
<tr id="status-fans" class="bg-light">
<th colspan="2"><i class="mdi mdi-fan"></i> Fans</th>
</tr>
<tr id="power">
<tr id="status-power" class="bg-light">
<th colspan="2"><i class="mdi mdi-power"></i> Power</th>
</tr>
<tr class="napalm-table-placeholder d-none invisible">
</tr>
</table>
</div>
</div>
@ -70,73 +90,6 @@
</div>
{% endblock %}
{% block javascript %}
<script type="text/javascript">
$(document).ready(function() {
$.ajax({
url: "{% url 'dcim-api:device-napalm' pk=object.pk %}?method=get_facts&method=get_environment",
dataType: 'json',
success: function(json) {
if (!json['get_facts']['error']) {
$('#hostname').html(json['get_facts']['hostname']);
$('#fqdn').html(json['get_facts']['fqdn']);
$('#vendor').html(json['get_facts']['vendor']);
$('#model').html(json['get_facts']['model']);
$('#serial_number').html(json['get_facts']['serial_number']);
$('#os_version').html(json['get_facts']['os_version']);
// Calculate uptime
var uptime = json['get_facts']['uptime'];
console.log(uptime);
var uptime_days = Math.floor(uptime / 86400);
var uptime_hours = Math.floor(uptime % 86400 / 3600);
var uptime_minutes = Math.floor(uptime % 3600 / 60);
$('#uptime').html(uptime_days + "d " + uptime_hours + "h " + uptime_minutes + "m");
}
if (!json['get_environment']['error']) {
$.each(json['get_environment']['cpu'], function(name, obj) {
var row="<tr><td>" + name + "</td><td>" + obj['%usage'] + "%</td></tr>";
$("#cpu").after(row)
});
if (json['get_environment']['memory']) {
var memory = $('#memory');
memory.after("<tr><td>Used</td><td>" + json['get_environment']['memory']['used_ram'] + "</td></tr>");
memory.after("<tr><td>Available</td><td>" + json['get_environment']['memory']['available_ram'] + "</td></tr>");
}
$.each(json['get_environment']['temperature'], function(name, obj) {
var style = "success";
if (obj['is_alert']) {
style = "warning";
} else if (obj['is_critical']) {
style = "danger";
}
var row="<tr class=\"" + style +"\"><td>" + name + "</td><td>" + obj['temperature'] + "°C</td></tr>";
$("#temperature").after(row)
});
$.each(json['get_environment']['fans'], function(name, obj) {
var row;
if (obj['status']) {
row="<tr class=\"success\"><td>" + name + "</td><td><i class=\"mdi mdi-check-bold text-success\"></i></td></tr>";
} else {
row="<tr class=\"error\"><td>" + name + "</td><td><i class=\"mdi mdi-close text-error\"></i></td></tr>";
}
$("#fans").after(row)
});
$.each(json['get_environment']['power'], function(name, obj) {
var row;
if (obj['status']) {
row="<tr class=\"success\"><td>" + name + "</td><td><i class=\"mdi mdi-check-bold text-success\"></i></td></tr>";
} else {
row="<tr class=\"danger\"><td>" + name + "</td><td><i class=\"mdi mdi-close text-danger\"></i></td></tr>";
}
$("#power").after(row)
});
}
},
error: function(xhr) {
alert(xhr.responseText);
}
});
});
</script>
{% block data %}
<span data-object-url="{% url 'dcim-api:device-napalm' pk=object.pk %}?method=get_facts&method=get_environment"></span>
{% endblock %}