diff --git a/netbox/project-static/dist/config.js b/netbox/project-static/dist/config.js index 2ee4e87fa..136e13742 100644 Binary files a/netbox/project-static/dist/config.js and b/netbox/project-static/dist/config.js differ diff --git a/netbox/project-static/dist/config.js.map b/netbox/project-static/dist/config.js.map index bfe61b03f..27edb0147 100644 Binary files a/netbox/project-static/dist/config.js.map and b/netbox/project-static/dist/config.js.map differ diff --git a/netbox/project-static/dist/jobs.js b/netbox/project-static/dist/jobs.js index 4811a4251..9d2865210 100644 Binary files a/netbox/project-static/dist/jobs.js and b/netbox/project-static/dist/jobs.js differ diff --git a/netbox/project-static/dist/jobs.js.map b/netbox/project-static/dist/jobs.js.map index ed8c7228e..1c58823d1 100644 Binary files a/netbox/project-static/dist/jobs.js.map and b/netbox/project-static/dist/jobs.js.map differ diff --git a/netbox/project-static/dist/lldp.js b/netbox/project-static/dist/lldp.js index c03527ee7..31665fe4b 100644 Binary files a/netbox/project-static/dist/lldp.js and b/netbox/project-static/dist/lldp.js differ diff --git a/netbox/project-static/dist/lldp.js.map b/netbox/project-static/dist/lldp.js.map index 0f0071323..4626e5748 100644 Binary files a/netbox/project-static/dist/lldp.js.map and b/netbox/project-static/dist/lldp.js.map differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index e864c268f..6abaa886d 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 54ba8f3ca..ed839f84c 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/dist/status.js b/netbox/project-static/dist/status.js new file mode 100644 index 000000000..2f808f940 Binary files /dev/null and b/netbox/project-static/dist/status.js differ diff --git a/netbox/project-static/dist/status.js.map b/netbox/project-static/dist/status.js.map new file mode 100644 index 000000000..80c5b88ec Binary files /dev/null and b/netbox/project-static/dist/status.js.map differ diff --git a/netbox/project-static/package.json b/netbox/project-static/package.json index a5bf7470f..328ccbad7 100644 --- a/netbox/project-static/package.json +++ b/netbox/project-static/package.json @@ -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", diff --git a/netbox/project-static/src/device/config.ts b/netbox/project-static/src/device/config.ts index 3ea96caa6..a4898ba8e 100644 --- a/netbox/project-static/src/device/config.ts +++ b/netbox/project-static/src/device/config.ts @@ -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', diff --git a/netbox/project-static/src/device/status.ts b/netbox/project-static/src/device/status.ts new file mode 100644 index 000000000..b06d17aa9 --- /dev/null +++ b/netbox/project-static/src/device/status.ts @@ -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, `${utc}`].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(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>(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(id: string): Nullable { + const element = document.getElementById(id); + if (element !== null) { + return element.nextElementSibling as Nullable; + } + 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('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('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('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 ${tempF} °F`; + 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('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('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(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); +} diff --git a/netbox/project-static/src/global.d.ts b/netbox/project-static/src/global.d.ts index 3fb669adf..cbe6f6fb9 100644 --- a/netbox/project-static/src/global.d.ts +++ b/netbox/project-static/src/global.d.ts @@ -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; } diff --git a/netbox/project-static/src/util.ts b/netbox/project-static/src/util.ts index 61b3c9a4a..a74e290e4 100644 --- a/netbox/project-static/src/util.ts +++ b/netbox/project-static/src/util.ts @@ -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 = + | { + [k in keyof E]: E[k]; + } + | {}; + +type InferredProps = HTMLElementProperties< + HTMLElementTagNameMap[T] +>; export function isApiError(data: Record): 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(base: E): SelectedOption[] { let selected = [] as SelectedOption[]; for (const element of base.querySelectorAll('select')) { @@ -186,6 +192,16 @@ export function getSelectedOptions(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('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( + tag: T, + properties: InferredProps, + classes: string[], + children: C[] = [], +): HTMLElementTagNameMap[T] { + // Create the base element. + const element = document.createElement(tag); + + for (const k of Object.keys(properties)) { + // Add each property to the element. + const key = k as keyof HTMLElementProperties; + 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; +} diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock index 2ca8f28f4..21fd08fa2 100644 --- a/netbox/project-static/yarn.lock +++ b/netbox/project-static/yarn.lock @@ -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" diff --git a/netbox/templates/dcim/device/status.html b/netbox/templates/dcim/device/status.html index 0e0fb0e8f..566889fdb 100644 --- a/netbox/templates/dcim/device/status.html +++ b/netbox/templates/dcim/device/status.html @@ -1,13 +1,21 @@ {% extends 'dcim/device/base.html' %} {% load static %} -{% block title %}{{ device }} - Status{% endblock %} +{% block title %}{{ object }} - Status{% endblock %} + +{% block head %} + +{% endblock %} {% block content %} - {% include 'inc/ajax_loader.html' %}
+
+
+ Loading... +
+
Device Facts
@@ -29,15 +37,20 @@ - + - + - +
Serial Number + +
OS Version
Uptime +
+
+
@@ -45,24 +58,31 @@
+
+
+ Loading... +
+
Environment
- + - + - + - + - + + +
CPU
Memory
Temperature
Fans
Power
@@ -70,73 +90,6 @@
{% endblock %} -{% block javascript %} - +{% block data %} + {% endblock %}