Просмотр исходного кода

migrate napalm device status to typescript

checktheroads 4 лет назад
Родитель
Сommit
08b955f8b6

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/config.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/config.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/jobs.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/jobs.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/lldp.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/lldp.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


Разница между файлами не показана из-за своего большого размера
+ 2 - 0
netbox/project-static/dist/status.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/status.js.map


+ 2 - 1
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",

+ 1 - 0
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',

+ 379 - 0
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, `<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);
+}

+ 41 - 0
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<APIReference>;
 }

+ 76 - 13
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<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;
+}

+ 5 - 0
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"

+ 32 - 79
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 %}
+<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 %}

Некоторые файлы не были показаны из-за большого количества измененных файлов