feat(plugins): Optimize plugin badge fetching and rendering to prevent flicker and enhance visibility

This commit is contained in:
Jokob @NetAlertX
2026-03-27 10:41:18 +00:00
parent cd0a3f6de0
commit 77369c3ce8

View File

@@ -274,6 +274,9 @@ function genericSaveData (id) {
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
pluginDefinitions = [] pluginDefinitions = []
// Global counts map, populated before tabs are rendered
let pluginCounts = {};
async function getData() { async function getData() {
try { try {
showSpinner(); showSpinner();
@@ -282,6 +285,10 @@ async function getData() {
const plugins = await fetchJson('plugins.json'); const plugins = await fetchJson('plugins.json');
pluginDefinitions = plugins.data; pluginDefinitions = plugins.data;
// Fetch counts BEFORE rendering tabs so we can skip empty plugins (no flicker)
const prefixes = pluginDefinitions.filter(p => p.show_ui).map(p => p.unique_prefix);
pluginCounts = await fetchPluginCounts(prefixes);
generateTabs(); generateTabs();
} catch (err) { } catch (err) {
console.error("Failed to load data", err); console.error("Failed to load data", err);
@@ -348,148 +355,117 @@ function postPluginGraphQL(gqlField, prefix, foreignKey, dtRequest, callback) {
}); });
} }
// Fetch badge counts for every plugin and populate sidebar + sub-tab counters. // Fetch counts for all plugins. Returns { PREFIX: { objects, events, history } }.
// Fast path: static JSON (~1KB) when no MAC filter is active. // Fast path: static JSON (~1KB) when no MAC filter is active.
// Filtered path: batched GraphQL aliases when a foreignKey (MAC) is set. // Filtered path: batched GraphQL aliases when a foreignKey (MAC) is set.
async function prefetchPluginBadges() { async function fetchPluginCounts(prefixes) {
if (prefixes.length === 0) return {};
const mac = $("#txtMacFilter").val(); const mac = $("#txtMacFilter").val();
const foreignKey = (mac && mac !== "--") ? mac : null; const foreignKey = (mac && mac !== "--") ? mac : null;
let counts = {};
const prefixes = pluginDefinitions if (!foreignKey) {
.filter(p => p.show_ui) // ---- FAST PATH: lightweight pre-computed JSON ----
.map(p => p.unique_prefix); const stats = await fetchJson('table_plugins_stats.json');
for (const row of stats.data) {
if (prefixes.length === 0) return; const p = row.tableName; // 'objects' | 'events' | 'history'
const plugin = row.plugin;
try { if (!counts[plugin]) counts[plugin] = { objects: 0, events: 0, history: 0 };
let counts = {}; // { PREFIX: { objects: N, events: N, history: N } } counts[plugin][p] = row.cnt;
if (!foreignKey) {
// ---- FAST PATH: lightweight pre-computed JSON ----
const stats = await fetchJson('table_plugins_stats.json');
for (const row of stats.data) {
const p = row.tableName; // 'objects' | 'events' | 'history'
const plugin = row.plugin;
if (!counts[plugin]) counts[plugin] = { objects: 0, events: 0, history: 0 };
counts[plugin][p] = row.cnt;
}
} else {
// ---- FILTERED PATH: GraphQL with foreignKey ----
const apiToken = getSetting("API_TOKEN");
const apiBase = getApiBase();
const fkOpt = `, foreignKey: "${foreignKey}"`;
const fragments = prefixes.map(p => [
`${p}_obj: pluginsObjects(options: {plugin: "${p}", page: 1, limit: 1${fkOpt}}) { dbCount }`,
`${p}_evt: pluginsEvents(options: {plugin: "${p}", page: 1, limit: 1${fkOpt}}) { dbCount }`,
`${p}_hist: pluginsHistory(options: {plugin: "${p}", page: 1, limit: 1${fkOpt}}) { dbCount }`,
].join('\n ')).join('\n ');
const query = `query BadgeCounts {\n ${fragments}\n }`;
const response = await $.ajax({
method: "POST",
url: `${apiBase}/graphql`,
headers: { "Authorization": `Bearer ${apiToken}`, "Content-Type": "application/json" },
data: JSON.stringify({ query }),
});
if (response.errors) { console.error("[plugins] badge GQL errors:", response.errors); return; }
for (const p of prefixes) {
counts[p] = {
objects: response.data[`${p}_obj`]?.dbCount ?? 0,
events: response.data[`${p}_evt`]?.dbCount ?? 0,
history: response.data[`${p}_hist`]?.dbCount ?? 0,
};
}
} }
} else {
// ---- FILTERED PATH: GraphQL with foreignKey ----
const apiToken = getSetting("API_TOKEN");
const apiBase = getApiBase();
const fkOpt = `, foreignKey: "${foreignKey}"`;
const fragments = prefixes.map(p => [
`${p}_obj: pluginsObjects(options: {plugin: "${p}", page: 1, limit: 1${fkOpt}}) { dbCount }`,
`${p}_evt: pluginsEvents(options: {plugin: "${p}", page: 1, limit: 1${fkOpt}}) { dbCount }`,
`${p}_hist: pluginsHistory(options: {plugin: "${p}", page: 1, limit: 1${fkOpt}}) { dbCount }`,
].join('\n ')).join('\n ');
// Update DOM const query = `query BadgeCounts {\n ${fragments}\n }`;
for (const [prefix, c] of Object.entries(counts)) { const response = await $.ajax({
$(`#badge_${prefix}`).text(c.objects); method: "POST",
$(`#objCount_${prefix}`).text(c.objects); url: `${apiBase}/graphql`,
$(`#evtCount_${prefix}`).text(c.events); headers: { "Authorization": `Bearer ${apiToken}`, "Content-Type": "application/json" },
$(`#histCount_${prefix}`).text(c.history); data: JSON.stringify({ query }),
}
// Zero out plugins with no rows in any table
prefixes.forEach(prefix => {
if (!counts[prefix]) {
$(`#badge_${prefix}`).text(0);
$(`#objCount_${prefix}`).text(0);
$(`#evtCount_${prefix}`).text(0);
$(`#histCount_${prefix}`).text(0);
}
}); });
if (response.errors) { console.error("[plugins] badge GQL errors:", response.errors); return counts; }
// Auto-hide tabs with zero results for (const p of prefixes) {
autoHideEmptyTabs(counts, prefixes); counts[p] = {
objects: response.data[`${p}_obj`]?.dbCount ?? 0,
} catch (err) { events: response.data[`${p}_evt`]?.dbCount ?? 0,
console.error('[plugins] badge prefetch failed:', err); history: response.data[`${p}_hist`]?.dbCount ?? 0,
};
}
} }
return counts;
} }
// --------------------------------------------------------------- // Apply pre-fetched counts to the DOM badges and hide empty tabs/sub-tabs.
// Hide plugin tabs (left-nav + pane) where all three counts are 0. function applyPluginBadges(counts, prefixes) {
// Within visible plugins, hide inner sub-tabs whose count is 0. // Update DOM badges
// If the active tab was hidden, activate the first visible one. for (const [prefix, c] of Object.entries(counts)) {
function autoHideEmptyTabs(counts, prefixes) { $(`#badge_${prefix}`).text(c.objects);
$(`#objCount_${prefix}`).text(c.objects);
$(`#evtCount_${prefix}`).text(c.events);
$(`#histCount_${prefix}`).text(c.history);
}
// Zero out plugins with no rows in any table
prefixes.forEach(prefix => { prefixes.forEach(prefix => {
const c = counts[prefix] || { objects: 0, events: 0, history: 0 }; if (!counts[prefix]) {
const total = c.objects + c.events + c.history; $(`#badge_${prefix}`).text(0);
const $li = $(`#tabs-location li:has(a[href="#${prefix}"])`); $(`#objCount_${prefix}`).text(0);
const $pane = $(`#tabs-content-location > #${prefix}`); $(`#evtCount_${prefix}`).text(0);
$(`#histCount_${prefix}`).text(0);
if (total === 0) {
// Hide the entire plugin tab and strip active from both nav item and pane
$li.removeClass('active').hide();
$pane.removeClass('active').css('display', '');
} else {
// Ensure nav item visible (in case a previous filter hid it)
$li.show();
// Clear any inline display override so Bootstrap CSS controls pane visibility via .active
$pane.css('display', '');
// Hide inner sub-tabs with zero count
const subTabs = [
{ href: `#objectsTarget_${prefix}`, count: c.objects },
{ href: `#eventsTarget_${prefix}`, count: c.events },
{ href: `#historyTarget_${prefix}`, count: c.history },
];
let activeSubHidden = false;
subTabs.forEach(st => {
const $subLi = $pane.find(`ul.nav-tabs li:has(a[href="${st.href}"])`);
const $subPane = $pane.find(st.href);
if (st.count === 0) {
if ($subLi.hasClass('active')) activeSubHidden = true;
$subLi.hide();
$subPane.removeClass('active').css('display', '');
} else {
$subLi.show();
$subPane.css('display', '');
}
});
// If the active inner sub-tab was hidden, activate the first visible one
// via Bootstrap's tab lifecycle so shown.bs.tab fires for deferred DataTable init
if (activeSubHidden) {
const $firstVisibleSubA = $pane.find('ul.nav-tabs li:visible:first a');
if ($firstVisibleSubA.length) {
$firstVisibleSubA.tab('show');
}
}
} }
}); });
// If the active left-nav tab was hidden, activate the first visible one // Auto-hide sub-tabs with zero results (outer tabs already excluded during creation)
const $activeLi = $(`#tabs-location li.active:visible`); autoHideEmptyTabs(counts, prefixes);
if ($activeLi.length === 0) { }
const $firstVisibleLi = $(`#tabs-location li:visible`).first();
if ($firstVisibleLi.length) { // ---------------------------------------------------------------
// Let Bootstrap's .tab('show') manage the active class on both // Within visible plugins, hide inner sub-tabs (Objects/Events/History) whose count is 0.
// the <li> and the pane — adding it manually beforehand causes // Outer plugin tabs with zero total are already excluded during tab creation.
// Bootstrap to bail out early without firing shown.bs.tab. function autoHideEmptyTabs(counts, prefixes) {
$firstVisibleLi.find('a').tab('show'); prefixes.forEach(prefix => {
const c = counts[prefix] || { objects: 0, events: 0, history: 0 };
const $pane = $(`#tabs-content-location > #${prefix}`);
// Hide inner sub-tabs with zero count
const subTabs = [
{ href: `#objectsTarget_${prefix}`, count: c.objects },
{ href: `#eventsTarget_${prefix}`, count: c.events },
{ href: `#historyTarget_${prefix}`, count: c.history },
];
let activeSubHidden = false;
subTabs.forEach(st => {
const $subLi = $pane.find(`ul.nav-tabs li:has(a[href="${st.href}"])`);
const $subPane = $pane.find(st.href);
if (st.count === 0) {
if ($subLi.hasClass('active')) activeSubHidden = true;
$subLi.hide();
$subPane.removeClass('active').css('display', '');
} else {
$subLi.show();
$subPane.css('display', '');
}
});
// If the active inner sub-tab was hidden, activate the first visible one
// via Bootstrap's tab lifecycle so shown.bs.tab fires for deferred DataTable init
if (activeSubHidden) {
const $firstVisibleSubA = $pane.find('ul.nav-tabs li:visible:first a');
if ($firstVisibleSubA.length) {
$firstVisibleSubA.tab('show');
}
} }
} });
} }
function generateTabs() { function generateTabs() {
@@ -502,31 +478,36 @@ function generateTabs() {
let assignActive = true; let assignActive = true;
// Iterate over the sorted pluginDefinitions to create tab headers and content // Build list of visible plugins (skip plugins with 0 total count)
pluginDefinitions.forEach(pluginObj => { const visiblePlugins = pluginDefinitions.filter(pluginObj => {
if (pluginObj.show_ui) { if (!pluginObj.show_ui) return false;
createTabContent(pluginObj, assignActive); const c = pluginCounts[pluginObj.unique_prefix] || { objects: 0, events: 0, history: 0 };
createTabHeader(pluginObj, assignActive); return (c.objects + c.events + c.history) > 0;
assignActive = false; });
}
// Create tab DOM for visible plugins only — no flicker
visiblePlugins.forEach(pluginObj => {
const prefix = pluginObj.unique_prefix;
const c = pluginCounts[prefix] || { objects: 0, events: 0, history: 0 };
createTabContent(pluginObj, assignActive, c);
createTabHeader(pluginObj, assignActive, c);
assignActive = false;
}); });
// Now that ALL DOM elements exist (both <a> headers and tab panes), // Now that ALL DOM elements exist (both <a> headers and tab panes),
// wire up DataTable initialization: immediate for the active tab, // wire up DataTable initialization: immediate for the active tab,
// deferred via shown.bs.tab for the rest. // deferred via shown.bs.tab for the rest.
let firstVisible = true; let firstVisible = true;
pluginDefinitions.forEach(pluginObj => { visiblePlugins.forEach(pluginObj => {
if (pluginObj.show_ui) { const prefix = pluginObj.unique_prefix;
const prefix = pluginObj.unique_prefix; const colDefinitions = getColumnDefinitions(pluginObj);
const colDefinitions = getColumnDefinitions(pluginObj); if (firstVisible) {
if (firstVisible) { initializeDataTables(prefix, colDefinitions, pluginObj);
firstVisible = false;
} else {
$(`a[href="#${prefix}"]`).one('shown.bs.tab', function() {
initializeDataTables(prefix, colDefinitions, pluginObj); initializeDataTables(prefix, colDefinitions, pluginObj);
firstVisible = false; });
} else {
$(`a[href="#${prefix}"]`).one('shown.bs.tab', function() {
initializeDataTables(prefix, colDefinitions, pluginObj);
});
}
} }
}); });
@@ -538,8 +519,9 @@ function generateTabs() {
tabContainer: '#tabs-location' tabContainer: '#tabs-location'
}); });
// Pre-fetch badge counts for every plugin in a single batched GraphQL call. // Apply badge counts to the DOM and hide empty inner sub-tabs
prefetchPluginBadges(); const prefixes = visiblePlugins.map(p => p.unique_prefix);
applyPluginBadges(pluginCounts, prefixes);
hideSpinner() hideSpinner()
} }
@@ -552,20 +534,18 @@ function resetTabs() {
// --------------------------------------------------------------- // ---------------------------------------------------------------
// left headers // left headers
function createTabHeader(pluginObj, assignActive) { function createTabHeader(pluginObj, assignActive, counts) {
const prefix = pluginObj.unique_prefix; // Get the unique prefix for the plugin const prefix = pluginObj.unique_prefix;
// Determine the active class for the first tab
const activeClass = assignActive ? "active" : ""; const activeClass = assignActive ? "active" : "";
const badgeText = counts ? counts.objects : '…';
// Append the tab header to the tabs location
$('#tabs-location').append(` $('#tabs-location').append(`
<li class="left-nav ${activeClass} "> <li class="left-nav ${activeClass} ">
<a class="col-sm-12 textOverflow" href="#${prefix}" data-plugin-prefix="${prefix}" id="${prefix}_id" data-toggle="tab"> <a class="col-sm-12 textOverflow" href="#${prefix}" data-plugin-prefix="${prefix}" id="${prefix}_id" data-toggle="tab">
${getString(`${prefix}_icon`)} ${getString(`${prefix}_display_name`)} ${getString(`${prefix}_icon`)} ${getString(`${prefix}_display_name`)}
</a> </a>
<div class="pluginBadgeWrap"><span title="" class="badge pluginBadge" id="badge_${prefix}"></span></div> <div class="pluginBadgeWrap"><span title="" class="badge pluginBadge" id="badge_${prefix}">${badgeText}</span></div>
</li> </li>
`); `);
@@ -573,14 +553,13 @@ function createTabHeader(pluginObj, assignActive) {
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Content of selected plugin (header) // Content of selected plugin (header)
function createTabContent(pluginObj, assignActive) { function createTabContent(pluginObj, assignActive, counts) {
const prefix = pluginObj.unique_prefix; // Get the unique prefix for the plugin const prefix = pluginObj.unique_prefix;
const colDefinitions = getColumnDefinitions(pluginObj); // Get column definitions for DataTables const colDefinitions = getColumnDefinitions(pluginObj);
// Append the content structure for the plugin's tab to the content location
$('#tabs-content-location').append(` $('#tabs-content-location').append(`
<div id="${prefix}" class="tab-pane ${assignActive ? 'active' : ''}"> <div id="${prefix}" class="tab-pane ${assignActive ? 'active' : ''}">
${generateTabNavigation(prefix)} <!-- Create tab navigation --> ${generateTabNavigation(prefix, counts)} <!-- Create tab navigation -->
<div class="tab-content"> <div class="tab-content">
${generateDataTable(prefix, 'Objects', colDefinitions)} ${generateDataTable(prefix, 'Objects', colDefinitions)}
${generateDataTable(prefix, 'Events', colDefinitions)} ${generateDataTable(prefix, 'Events', colDefinitions)}
@@ -601,19 +580,22 @@ function getColumnDefinitions(pluginObj) {
return pluginObj["database_column_definitions"].filter(colDef => colDef.show); return pluginObj["database_column_definitions"].filter(colDef => colDef.show);
} }
function generateTabNavigation(prefix) { function generateTabNavigation(prefix, counts) {
// Create navigation tabs for Objects, Unprocessed Events, and History const objCount = counts ? counts.objects : '…';
const evtCount = counts ? counts.events : '…';
const histCount = counts ? counts.history : '…';
return ` return `
<div class="nav-tabs-custom" style="margin-bottom: 0px"> <div class="nav-tabs-custom" style="margin-bottom: 0px">
<ul class="nav nav-tabs"> <ul class="nav nav-tabs">
<li class="active"> <li class="active">
<a href="#objectsTarget_${prefix}" data-toggle="tab"><i class="fa fa-cube"></i> ${getString('Plugins_Objects')} (<span id="objCount_${prefix}"></span>)</a> <a href="#objectsTarget_${prefix}" data-toggle="tab"><i class="fa fa-cube"></i> ${getString('Plugins_Objects')} (<span id="objCount_${prefix}">${objCount}</span>)</a>
</li> </li>
<li> <li>
<a href="#eventsTarget_${prefix}" data-toggle="tab"><i class="fa fa-bolt"></i> ${getString('Plugins_Unprocessed_Events')} (<span id="evtCount_${prefix}"></span>)</a> <a href="#eventsTarget_${prefix}" data-toggle="tab"><i class="fa fa-bolt"></i> ${getString('Plugins_Unprocessed_Events')} (<span id="evtCount_${prefix}">${evtCount}</span>)</a>
</li> </li>
<li> <li>
<a href="#historyTarget_${prefix}" data-toggle="tab"><i class="fa fa-clock"></i> ${getString('Plugins_History')} (<span id="histCount_${prefix}"></span>)</a> <a href="#historyTarget_${prefix}" data-toggle="tab"><i class="fa fa-clock"></i> ${getString('Plugins_History')} (<span id="histCount_${prefix}">${histCount}</span>)</a>
</li> </li>
</ul> </ul>
</div> </div>