BE+FE: change log

This commit is contained in:
jokob-sk
2026-07-06 13:03:38 +10:00
parent e0449bed8e
commit 887de65eb9
15 changed files with 676 additions and 286 deletions

73
docs/DEVICE_CHANGE_LOG.md Normal file
View File

@@ -0,0 +1,73 @@
# Change log
The **_Change log_** provides a historical record of changes made to your devices. It allows you to see **what changed, when it changed, and what caused the change**, making it easier to troubleshoot unexpected updates, audit configuration changes, and understand how device information evolves over time.
![Device Change log](./img/DEVICE_MANAGEMENT/device_change_log.png)
## What is recorded?
Whenever a tracked device property changes, NetAlertX records:
* **Time** the change occurred
* **Source** that made the change
* **Device** that was updated
* **Field** that changed
* **Previous value**
* **New value**
Related changes that occur at the same time are grouped together into a single event, making it easy to see all updates from one scan or user action.
## Understanding the Source column
The **Source** column identifies what caused the change.
Depending on the field, the source may be:
* A discovery plugin such as **ARPSCAN**, **NSLOOKUP**, **UNIFIAPI**, or another plugin that supplied the information.
* **user:api** for changes made manually through the NetAlertX interface or API.
* **system** for values calculated or maintained internally by NetAlertX.
This helps distinguish between information discovered automatically and changes made by users.
## Filtering the history
The _Change log_ includes filters to help locate specific events.
You can filter by:
* **Source** to view changes made by a specific plugin or user.
* **Changed Field** to display changes to a particular device property, such as IP address or hostname.
You can also use the search box to quickly find matching devices by GUID, values, or fields.
## Viewing grouped changes
A single action often updates multiple properties on a device.
Instead of showing each field as a separate record, the _Change log_ groups related updates together. For example, a network scan might update both a device's IP address and hostname at the same time. These changes appear together in one entry with each modified field listed underneath.
## Retention
The amount of history retained is controlled by the **Device History Days (`DEV_HIST_DAYS`)** setting.
Older history entries are automatically removed once they exceed the configured retention period. this is maintained by the `DBCLNP` plugin.
Setting this value to **0** disables _Change log_ recording entirely.
## Choosing what to track
Not every device property needs to be recorded.
The **Tracked Device History Fields (`DEV_HIST_TRACKED`)** setting lets you choose exactly which device fields should be monitored. Tracking only the fields that are important for your environment can significantly reduce database growth while keeping the most useful audit information.
## Performance considerations
Recording history introduces additional database writes whenever tracked fields change. For most installations the overhead is minimal, but on larger networks it can generate a substantial amount of historical data.
If you want to reduce storage usage or improve performance, consider:
* Tracking only the device fields that matter to you.
* Reducing the history retention period.
* Disabling the _Change log_ entirely by setting **`DEV_HIST_DAYS`** to **0**.
These settings allow you to balance historical visibility with database size and performance. For more tips on how to optimize system resource use check the [performance guide](./PERFORMANCE.md).

45
docs/DEVICE_PRESENCE.md Normal file
View File

@@ -0,0 +1,45 @@
## Presence View
![Device presence](./img/DEVICE_MANAGEMENT/device_presence.png)
The **Presence View** provides a visual timeline of when each device was detected on your network, making it easy to understand activity over time instead of relying only on the current status.
Use this view to answer questions such as:
* When was this device first discovered?
* When was it last seen online?
* How often does this device connect to the network?
* Has the device been continuously online or only appearing intermittently?
* When did the device disappear or return?
The timeline makes it easy to identify recurring activity patterns, unexpected outages, or devices that only appear at certain times of the day or week.
### Timeline Views
The timeline can be displayed at different time scales depending on the level of detail you need:
* **Week** View recent activity with the highest level of detail.
* **Month** Review activity across the past month.
* **Year** Get a long-term overview of device presence and seasonal patterns.
Switch between these views using the **Week**, **Month**, and **Year** buttons above the timeline.
### Timeline Legend
The timeline uses color coding to indicate device presence:
* <div class="presenceOnlineNow presenceKeyBoxes" style="display:inline-block;vertical-align:middle;"></div> **Now online** The device was detected during the most recent scan and is currently online.
* <div class="presenceOnlinePast presenceKeyBoxes" style="display:inline-block;vertical-align:middle;"></div> **Past online** The device was online previously but is currently offline.
* <div class="presenceOnlinePastMiss presenceKeyBoxes" style="display:inline-block;vertical-align:middle;"></div> **Past online (mismatch)** Historical activity was detected, but the beginning of the session could not be determined or conflicting data was encountered.
### Typical Uses
The Presence View is especially useful for:
* Troubleshooting intermittent connectivity issues.
* Identifying devices that frequently disconnect and reconnect.
* Understanding occupancy or usage patterns for phones, laptops, IoT devices, and other network equipment.
* Verifying when visitors' devices or temporary equipment were present on the network.
* Reviewing historical presence without searching through individual events or logs.
Unlike the Device Details page, which shows the current state of a device, the Presence View focuses on **when** devices were active, providing valuable historical context at a glance. To investigate exactly **what** changed during a device's lifetime, see the [Change Log](./DEVICE_CHANGE_LOG.md).

View File

@@ -122,13 +122,43 @@ NetAlertX supports [device relationships](./NETWORK_TREE.md) to better represent
Many monitoring systems focus only on the current state.
NetAlertX also records historical information so you can answer questions like:
NetAlertX also records historical information, helping you understand how devices behave over time.
You can answer questions like:
* When was this device first discovered?
* When was it last online?
* Which ports have changed over time?
* How does its network presence change over time?
* Which configuration attributes have changed?
Historical data provides operational context without requiring a separate logging platform.
Two key views help explore this history:
* **Presence View** when a device was active on the network
* **Change log** what changed in the devices configuration over time
---
## Change log
![Device change log](./img/DEVICE_MANAGEMENT/device_change_log.png)
The **Change log** records all tracked changes to device attributes, showing what changed and when it happened.
Each entry includes:
* Timestamp of the change
* Previous and new values
* Source of the change (plugin, workflow, or user action)
You can filter, search, and sort changes to quickly investigate events such as IP changes, hostname updates, VLAN changes, or device classification updates.
| View | Answers |
| ----------------- | ------------------- |
| **Presence View** | When was it active? |
| **Change log** | What changed? |
Historical information provides valuable operational context without requiring a separate logging platform.
---

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

View File

@@ -14,6 +14,19 @@
* limitations under the License.
*/
:root {
--color-white: #fff;
--color-aqua: #00c0ef;
--color-lightblue: #3c8dbc;
--color-blue: #0060df;
--color-green: #00a65a;
--color-yellow: #f39c12;
--color-red: #dd4b39;
--color-gray: #8c8c8c;
--color-darkgray: #3f3e3e;
--color-lightgray: #bec5cb;
--color-black: #000;
}
/* --- HERO SECTION --- */
.hero-wrapper {
@@ -143,3 +156,25 @@ p.hero-description {
.learn-more-btn:hover {
opacity: 0.8;
}
/* key presence styles */
.presenceOnlineNow{
background-color: var(--color-green);
}
.presenceOnlinePast{
background-color: var(--color-blue);
}
.presenceOnlinePastMiss{
background-color: var(--color-yellow);
}
.presenceKeyBoxes
{
width: 10px;
height: 10px;
display: inline-block;
/* background: #fff; */
opacity: .75;
}

View File

@@ -2,12 +2,6 @@
require 'php/templates/header.php';
?>
<div class="content-wrapper changeHistoryPage spinnerTarget" id="changeHistoryPage">
<section class="content-header">
<h1>
<?= lang('Navigation_ChangeHistory'); ?>
<small><?= lang('device_history_col_changes'); ?></small>
</h1>
</section>
<?php
require 'changeLogCore.php';
?>

View File

@@ -1,311 +1,413 @@
<script>
showSpinner();
</script>
<?php require 'php/templates/skel_change_log.php'; ?>
<section class="content">
<div class="row">
<div class="col-xs-12">
<div class="box">
<!-- Filter bar -->
<div class="box-header " style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<select id="filterChangedBy" class="form-control" style="width:200px;">
<option value=""><?= lang('device_history_col_source'); ?> - All</option>
</select>
<select id="filterChangedColumn" class="form-control" style="width:200px;">
<option value=""><?= lang('device_history_col_dropdown'); ?> - All</option>
</select>
</div>
<?php require 'php/templates/skel_change_log.php'; ?>
<!-- DataTable -->
<div class="box-body">
<table id="changeHistoryTable" class="table table-bordered table-striped">
<thead>
<tr>
<th><?= lang('device_history_col_time'); ?></th>
<th><?= lang('device_history_col_source'); ?></th>
<th><?= lang('gen_device'); ?></th>
<th><?= lang('device_history_table_title_changes'); ?></th>
</tr>
</thead>
<tbody id="changeHistoryBody">
<tr><td colspan="4" class="text-center">
<i class="fa fa-spinner fa-spin"></i>
</td></tr>
</tbody>
</table>
</div>
<section class="content change-log">
<!-- Help link -->
<span class="helpIcon">
<a target="_blank" href="https://docs.netalertx.com/DEVICE_CHANGE_LOG">
<i class="fa fa-circle-question"></i>
</a>
</span>
<div class="row">
<div class="col-xs-12">
<div class="box">
<!-- ===================== -->
<!-- Filter Bar -->
<!-- ===================== -->
<div class="box-header" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<select id="filterChangedBy" class="form-control" style="width:200px;">
<option value=""><?= lang('device_history_col_source'); ?> - All</option>
</select>
<select id="filterChangedColumn" class="form-control" style="width:200px;">
<option value=""><?= lang('device_history_col_dropdown'); ?> - All</option>
</select>
<?= lang('device_history_col_changes'); ?>
</div>
<!-- ===================== -->
<!-- Data Table -->
<!-- ===================== -->
<div class="box-body">
<table id="changeHistoryTable" class="table table-bordered table-striped">
<thead>
<tr>
<th><?= lang('device_history_col_time'); ?></th>
<th><?= lang('device_history_col_source'); ?></th>
<th><?= lang('gen_device'); ?></th>
<th><?= lang('device_history_table_title_changes'); ?></th>
</tr>
</thead>
<tbody id="changeHistoryBody">
<tr>
<td colspan="4" class="text-center">
<i class="fa fa-spinner fa-spin"></i>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
</div>
</section>
<script>
/**
* =========================
* State
* =========================
*/
let historyTable = null;
let changeLogPageInitialized = false;
let historyTable;
const urlMac = getMacFromUrl();
const urlMac = getMacFromUrl();
function initializeHistoryTable() {
/**
* =========================
* Table Initialization
* =========================
*/
function initializeHistoryTable() {
historyTable = $('#changeHistoryTable').DataTable({
processing: true,
serverSide: true,
paging: true,
searching: true,
ordering: true,
info: true,
autoWidth: false,
processing: false,
serverSide: true,
paging: true,
searching: true,
ordering: true,
info: true,
autoWidth: false,
pageLength: getSetting("UI_DEFAULT_PAGE_SIZE"),
lengthMenu: getLengthMenu(getSetting("UI_DEFAULT_PAGE_SIZE")),
pageLength: getSetting("UI_DEFAULT_PAGE_SIZE"),
lengthMenu: getLengthMenu(getSetting("UI_DEFAULT_PAGE_SIZE")),
ajax: function (dtRequest, callback) {
/**
* Server-side AJAX loader (GraphQL)
*/
ajax: function (dtRequest, callback) {
const limit = parseInt(dtRequest.length, 10) || 50;
const start = parseInt(dtRequest.start, 10) || 0;
const page = Math.floor(start / limit) + 1;
const search = dtRequest.search?.value || null;
const order = dtRequest.order?.[0];
showSpinner();
devGUID = null;
// DataTables paging
const limit = parseInt(dtRequest.length, 10) || 50;
const start = parseInt(dtRequest.start, 10) || 0;
const page = Math.floor(start / limit) + 1;
const search = dtRequest.search?.value || null;
const order = dtRequest.order?.[0];
if(urlMac)
{
devGUID = getDevDataByMac(urlMac,"devGUID")
}
const apiBaseUrl = getApiBase();
const url = `${apiBaseUrl}/graphql`;
const columns = [
"timestamp",
"changedBy",
"devGUID",
null // changes column (not sortable)
];
console.log(url);
const sort = [];
if (order) {
const field = columns[order.column];
if (field) {
sort.push({
field: field,
order: order.dir
});
}
}
const query = `
query(
$devGUID:String,
$changedColumn:String,
$changedBy:String,
$options:PageQueryOptionsInput
){
deviceHistoryGrouped(
devGUID:$devGUID
changedColumn:$changedColumn
changedBy:$changedBy
options:$options
){
count
history{
devGUID
timestamp
changedBy
changes{
changedColumn
oldValue
newValue
}
}
}
}
`;
console.log(query)
$.ajax({
url: "/server/graphql",
method: "POST",
contentType: "application/json",
headers: {
Authorization: "Bearer " + getSetting("API_TOKEN")
},
data: JSON.stringify({
query,
variables: {
changedBy: $('#filterChangedBy').val() || null,
changedColumn: $('#filterChangedColumn').val() || null,
devGUID: devGUID,
options: {
page: page,
limit: limit,
sort: sort,
search: search
}
}
}),
success: function (resp) {
const result = resp?.data?.deviceHistoryGrouped;
callback({
data: result?.history || [],
recordsTotal: result?.count || 0,
recordsFiltered: result?.count || 0
});
hideSpinner();
hideChangeLogSkeleton();
},
error: function () {
callback({
data: [],
recordsTotal: 0,
recordsFiltered: 0
});
}
});
},
columns: [
{ data: "timestamp" },
{ data: "changedBy" },
{ data: "devGUID" },
{ data: "changes" }
],
columnDefs: [
{
targets: 0,
render: function (data) {
return localizeTimestamp(data);
}
},
{
targets: 1,
render: function (data) {
return `${escHtml(data)}`;
}
},
{
targets: 2,
render: function (data) {
return `<a href="deviceDetails.php?mac=${getDevDataByGuid(escHtml(data), "devMac")}" data-guid="${escHtml(data)}">${getDevDataByGuid(escHtml(data), "devName")} (${escHtml(data)}) </a>`;
}
},
{
targets: 3,
orderable: false,
searchable: false,
render: function (changes) {
if (!Array.isArray(changes)) return "";
return changes.map(c =>
`<div>
<code>${escHtml(c.changedColumn)}</code>:
<span class="text-danger">${escHtml(c.oldValue || "∅")}</span>
<span class="text-success">${escHtml(c.newValue || "∅")}</span>
</div>`
).join("");
}
}
],
language: {
processing: '<i class="fa fa-spinner fa-spin"></i>',
emptyTable: "<?= lang('device_history_empty_state'); ?>"
// Resolve device filter from MAC if present
let devGUID = null;
if (urlMac) {
devGUID = getDevDataByMac(urlMac, "devGUID");
}
});
}
function bindHistoryFilters() {
// Column mapping for sorting
const columns = [
"timestamp",
"changedBy",
"devGUID",
null
];
const sort = [];
if (order) {
const field = columns[order.column];
if (field) {
sort.push({
field: field,
order: order.dir
});
}
}
/**
* GraphQL query
*/
const query = `
query(
$devGUID:String,
$changedColumn:String,
$changedBy:String,
$options:PageQueryOptionsInput
){
deviceHistoryGrouped(
devGUID:$devGUID
changedColumn:$changedColumn
changedBy:$changedBy
options:$options
){
count
history{
devGUID
timestamp
changedBy
changes{
changedColumn
oldValue
newValue
}
}
}
}
`;
$.ajax({
url: url,
method: "POST",
contentType: "application/json",
headers: {
Authorization: "Bearer " + getSetting("API_TOKEN")
},
data: JSON.stringify({
query,
variables: {
changedBy: $('#filterChangedBy').val() || null,
changedColumn: $('#filterChangedColumn').val() || null,
devGUID: devGUID,
options: {
page,
limit,
sort,
search
}
}
}),
success: function (resp) {
const result = resp?.data?.deviceHistoryGrouped;
callback({
data: result?.history || [],
recordsTotal: result?.count || 0,
recordsFiltered: result?.count || 0
});
hideSpinner();
hideChangeLogSkeleton();
},
error: function (xhr, textStatus, errorThrown) {
console.error("History GraphQL request failed", {
status: xhr.status,
statusText: xhr.statusText,
textStatus,
errorThrown,
response: xhr.responseText,
variables: {
changedBy: $('#filterChangedBy').val() || null,
changedColumn: $('#filterChangedColumn').val() || null,
devGUID,
options: {
page,
limit,
sort,
search
}
}
});
callback({
data: [],
recordsTotal: 0,
recordsFiltered: 0
});
}
});
},
/**
* Column definitions
*/
columns: [
{ data: "timestamp" },
{ data: "changedBy" },
{ data: "devGUID" },
{ data: "changes" }
],
columnDefs: [
{
targets: 0,
render: data => localizeTimestamp(data)
},
{
targets: 1,
render: data => escHtml(data)
},
{
targets: 2,
render: function (data) {
const guid = escHtml(data);
const mac = getDevDataByGuid(guid, "devMac");
const name = getDevDataByGuid(guid, "devName");
return `
<a href="deviceDetails.php?mac=${mac}" data-guid="${guid}">
${name} (${guid})
</a>`;
}
},
{
targets: 3,
orderable: false,
searchable: false,
render: function (changes) {
if (!Array.isArray(changes)) return "";
return changes.map(c => `
<div>
<code>${escHtml(c.changedColumn)}</code>:
<span class="text-danger">${escHtml(c.oldValue || "∅")}</span>
<span class="text-success">${escHtml(c.newValue || "∅")}</span>
</div>
`).join("");
}
}
],
language: {
processing: '<i class="fa fa-spinner fa-spin"></i>',
emptyTable: "<?= lang('device_history_empty_state'); ?>"
}
});
}
/**
* =========================
* Filters
* =========================
*/
function bindHistoryFilters() {
$('#filterChangedBy, #filterChangedColumn').on('change', function () {
if (historyTable) {
showSpinner();
historyTable.ajax.reload(null, true); // reset paging
}
});
}
function escHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function populateFilters() {
var query = `
query {
allDeviceHistoryGrouped(limit: 1) {
history { changedBy changes { changedColumn } }
if (historyTable) {
showSpinner();
historyTable.ajax.reload(null, true);
}
});
}
function populateFilters() {
const apiBaseUrl = getApiBase();
const apiEndpoint = '/devices/history/filters';
let queryString = "";
if (urlMac) {
queryString += `?devGUID=${getDevDataByMac(urlMac, "devGUID")}`;
}
`;
url = '/server/devices/history/filters';
const url = `${apiBaseUrl}/${apiEndpoint}${encodeURIComponent(queryString)}`;
if(urlMac)
{
url = `${url}?devGUID=${getDevDataByMac(urlMac,"devGUID")}`;
}
$.ajax({
url,
method: 'GET',
headers: {
Authorization: 'Bearer ' + getSetting('API_TOKEN')
},
// Fetch distinct filter values from the filter-values endpoint
$.ajax({
url: url,
method: 'GET',
headers: { 'Authorization': 'Bearer ' + getSetting('API_TOKEN') },
success: function(resp) {
if (!resp.data) return;
success: function (resp) {
var byEl = document.getElementById('filterChangedBy');
var colEl = document.getElementById('filterChangedColumn');
if (!resp.data) return;
(resp.data.changedBy || []).forEach(function(v) {
byEl.add(new Option(v, v));
});
const byEl = document.getElementById('filterChangedBy');
const colEl = document.getElementById('filterChangedColumn');
(resp.data.changedColumn || []).forEach(function(v) {
colEl.add(new Option(v, v));
});
(resp.data.changedBy || []).forEach(v => byEl.add(new Option(v, v)));
(resp.data.changedColumn || []).forEach(v => colEl.add(new Option(v, v)));
// trigger initial load AFTER filters are ready
$('#changeHistoryTable').DataTable().ajax.reload();
}
});
}
if ($.fn.dataTable.isDataTable('#changeHistoryTable')) {
$('#changeHistoryTable').DataTable().ajax.reload();
}
}
});
}
function getMacFromUrl() {
/**
* =========================
* Helpers
* =========================
*/
function escHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function getMacFromUrl() {
return new URLSearchParams(window.location.search).get('mac');
}
}
$(document).ready(function () {
callAfterAppInitialized(initChangeLog);
});
/**
* =========================
* Page Init
* =========================
*/
function initChangeLogPage() {
if ($('#panHistory').length !== 0 && !$('#panHistory:visible').length) {
return;
}
if (changeLogPageInitialized) return;
changeLogPageInitialized = true;
showChangeLogSkeleton();
showSpinner();
function initChangeLog(){
populateFilters();
initializeHistoryTable();
bindHistoryFilters();
}
}
function hideChangeLogSkeleton() {
$('#change-log-skeleton').fadeOut(0, function() { $(this).hide(); });
}
initLazyTab('#panHistory', function () {
waitForAppReady(function () {
initChangeLogPage();
});
});
function showChangeLogSkeleton() {
var $skel = $('#change-log-skeleton');
$skel.stop(true, true).fadeIn(0);
}
/**
* =========================
* Skeleton UI
* =========================
*/
function hideChangeLogSkeleton() {
$('#change-log-skeleton').fadeOut(0, function () {
$(this).hide();
});
}
function showChangeLogSkeleton() {
$('#change-log-skeleton')
.stop(true, true)
.fadeIn(0);
}
</script>

View File

@@ -1001,6 +1001,19 @@ height: 50px;
opacity: .75;
}
#presencePage .helpIcon{
top: 2px;
display: initial;
font-size: x-small;
}
.calendarHelpIcon{
display: block;
position: absolute;
font-size: x-small;
top: -5px;
}
/* --------------------------------------------------------- */
/* report */
/* --------------------------------------------------------- */
@@ -2636,7 +2649,18 @@ table.dataTable tbody > tr.selected
background-color: rgba(140, 140, 140, 0.05);
}
/* ===== Loading Skeleton ===== */
/* -----------------------------------------------------------------------------
Change log
----------------------------------------------------------------------------- */
.change-log .helpIcon{
left: 15px;
font-size: x-small;
}
/* -----------------------------------------------------------------------------
Loading Skeletons
----------------------------------------------------------------------------- */
/* Custom properties (overridden in dark-patch.css for Dark/System themes) */
:root {

View File

@@ -196,12 +196,7 @@ function initDeviceEventsPage()
// -----------------------------------------------------------------------------
// Recurring function to monitor the URL and reinitialize if needed
function deviceEventsPageUpdater() {
initDeviceEventsPage();
// Run updater again after delay
setTimeout(deviceEventsPageUpdater, 200);
}
initLazyTab('#panEvents', initDeviceEventsPage);
deviceEventsPageUpdater();

View File

@@ -21,6 +21,11 @@
<!-- Calendar -->
<span class="calendarHelpIcon">
<a target="_blank" href="https://docs.netalertx.com/DEVICE_PRESENCE">
<i class="fa fa-circle-question"></i>
</a>
</span>
<div id="calendar">
</div>

View File

@@ -1175,6 +1175,82 @@ function isSQLQuery(query) {
return sqlRegex.test(query);
}
/**
* Lazy tab initializer
* - Ensures a tab is only initialized once
* - Waits until tab is visible in DOM
* - Uses polling (compatible with your existing architecture)
*/
function waitForAppReady(cb) {
if (window.__appReady) {
cb();
return;
}
const t = setInterval(() => {
if (window.__appReady) {
clearInterval(t);
cb();
}
}, 50);
}
function initLazyTab(selector, initFn, intervalMs = 200) {
const stateKey = `__lazy_init_${selector}`;
let initialized = false;
function runner() {
// prevent duplicate execution
if (initialized || window[stateKey]) return;
const $el = $(selector);
// ---------------------------------------------------------
// CASE 1: element does not exist → standalone page variant
// run immediately (no DOM dependency)
// ---------------------------------------------------------
if (!$el.length) {
initialized = true;
window[stateKey] = true;
try {
initFn();
} catch (e) {
console.error("Lazy init (no-element) failed:", selector, e);
}
return;
}
// ---------------------------------------------------------
// CASE 2: element exists AND is visible → run immediately
// ---------------------------------------------------------
if ($el.is(':visible')) {
initialized = true;
window[stateKey] = true;
try {
initFn();
} catch (e) {
console.error("Lazy init (visible) failed:", selector, e);
}
return;
}
// ---------------------------------------------------------
// CASE 3: element exists but hidden → wait for visibility
// (tab mode)
// ---------------------------------------------------------
setTimeout(runner, intervalMs);
}
runner();
}
// -------------------------------------------------------------------
// Get corresponding plugin setting object

View File

@@ -1164,5 +1164,5 @@ function getLengthMenu(newEntry) {
return [values, labels];
}
window.__appReady = true;
console.log("init ui_components.js")

View File

@@ -777,7 +777,7 @@
"add_icon_event_tooltip": "Add new icon",
"add_option_event_tooltip": "Add new value",
"copy_icons_event_tooltip": "Overwrite icons of all devices with the same device type",
"device_history_col_changes": "Select which columns are tracked via the <a href=\"/settings.php#DEV_HIST_TRACKED\" target=\"_blank\"><code>DEV_HIST_TRACKED</code> </a> setting.",
"device_history_col_changes": "Select which columns are tracked via the <a href=\"/settings.php#DEV_HIST_TRACKED\" target=\"_blank\"><code>DEV_HIST_TRACKED</code></a> setting.",
"device_history_col_dropdown": "Columns",
"device_history_col_source": "Modified By",
"device_history_col_time": "Timestamp",

View File

@@ -22,6 +22,7 @@
<!-- Page ------------------------------------------------------------------ -->
<div class="content-wrapper" id="presencePage">
<?php require 'php/templates/skel_presence.php'; ?>
<!-- Main content ---------------------------------------------------------- -->
@@ -128,9 +129,11 @@
<!-- Calendar -------------------------------------------------------------- -->
<div class="row">
<div class="col-lg-12 col-sm-12 col-xs-12">
<div id="tableDevicesBox" class="box" style="min-height: 500px">
<!-- box-header -->
<div class="box-header">
<h3 id="tableDevicesTitle" class="box-title text-gray">Devices</h3>
@@ -145,6 +148,11 @@
<div class="presenceOnlinePastMiss presenceKeyBoxes"></div> <?= lang('Presence_Key_OnlinePastMiss');?>
</span>
</div>
<span class="helpIcon">
<a target="_blank" href="https://docs.netalertx.com/DEVICE_PRESENCE">
<i class="fa fa-circle-question"></i>
</a>
</span>
</div>
<!-- box-body -->
@@ -456,7 +464,7 @@ function getDevicesPresence (status) {
// Set title and color
$('#tableDevicesTitle')[0].className = 'box-title text-'+ color;
$('#tableDevicesBox')[0].className = 'box box-'+ color;
$('#tableDevicesTitle').html (tableTitle);
$('#tableDevicesTitle').html (`${tableTitle} ${getString("Device_TableHead_PresentLastScan")}`);
const apiToken = getSetting("API_TOKEN");

View File

@@ -74,7 +74,10 @@ nav:
- Device display settings: DEVICE_DISPLAY_SETTINGS.md
- Device views filtering: DEVICE_FILTERS.md
- Session info: SESSION_INFO.md
- Field Lock/Unlock: DEVICE_FIELD_LOCK.md
- Field Lock/Unlock: DEVICE_FIELD_LOCK.md
- Device auditing:
- Change log: DEVICE_CHANGE_LOG.md
- Presence: DEVICE_PRESENCE.md
- Device source fields: DEVICE_SOURCE_FIELDS.md
- Icons and network topology:
- Icons: ICONS.md