From 2eb3f88426457fa5dcebe3d0fc427f47243d68ae Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Thu, 2 Jul 2026 08:08:31 +1000 Subject: [PATCH 01/11] BE: clearer setings initializing message #1696 --- server/helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/helper.py b/server/helper.py index b9d728cc..64f35acf 100755 --- a/server/helper.py +++ b/server/helper.py @@ -159,7 +159,7 @@ def get_setting(key): try: fileModifiedTime = os.path.getmtime(settingsFile) except FileNotFoundError: - mylog("none", [f"[Settings] ⚠ File not found: {settingsFile}"]) + mylog("none", [f"[Settings] ⚠ Settings file not found: {settingsFile} (backend may still be initializing)"]) return None mylog("trace", f"[Import table_settings.json] checking table_settings.json file SETTINGS_LASTCACHEDATE: {SETTINGS_LASTCACHEDATE} fileModifiedTime: {fileModifiedTime}") From 6c593d88791563fb8a9158c2297531c8b37c997e Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:15:00 +0000 Subject: [PATCH 02/11] Enhance plugin string handling and initialization; add UI tests for translation loading --- front/js/app-init.js | 21 ++-- front/js/cache.js | 121 ++++++++++++++-------- front/multiEditCore.php | 4 +- front/pluginsCore.php | 18 ++-- front/workflowsCore.php | 4 +- test/ui/test_ui_translations.py | 176 ++++++++++++++++++++++++++++++++ 6 files changed, 283 insertions(+), 61 deletions(-) create mode 100644 test/ui/test_ui_translations.py diff --git a/front/js/app-init.js b/front/js/app-init.js index fe294043..7ec62d7c 100644 --- a/front/js/app-init.js +++ b/front/js/app-init.js @@ -16,7 +16,7 @@ // ----------------------------------------------------------------------------- var completedCalls = [] -var completedCalls_final = ['cacheApiConfig', 'cacheSettings', 'cacheStrings_v2', 'cacheDevices']; +var completedCalls_final = ['cacheApiConfig', 'cacheSettings', 'cacheStrings_v2', 'cachePluginStrings_v1', 'cacheDevices']; var lang_completedCalls = 0; @@ -185,10 +185,11 @@ async function executeOnce() { try { await waitForGraphQLServer(); // Wait for the server to start - await retryStep('cacheApiConfig', cacheApiConfig); // Bootstrap: API_TOKEN + GRAPHQL_PORT from app.conf - await retryStep('cacheDevices', cacheDevices); - await retryStep('cacheSettings', cacheSettings); - await retryStep('cacheStrings', cacheStrings); + await retryStep('cacheApiConfig', cacheApiConfig); // Bootstrap: API_TOKEN + GRAPHQL_PORT from app.conf + await retryStep('cacheDevices', cacheDevices); + await retryStep('cacheSettings', cacheSettings); + await retryStep('cacheStrings', cacheStrings); + await retryStep('cachePluginStrings', cachePluginStrings); console.log("All AJAX callbacks have completed"); onAllCallsComplete(); @@ -254,9 +255,13 @@ const onAllCallsComplete = () => { // Function to check if all necessary strings are initialized const areAllStringsInitialized = () => { - // Implement logic to check if all necessary strings are initialized - // Return true if all strings are initialized, false otherwise - return getString('UI_LANG_name') != "" + // Core string check — comes from en_us.json + if (getString('UI_LANG_name') === '') return false; + // Plugin string check — NEWDEV is a necessary plugin (always loaded) and always + // generates the 'NEWDEV_display_name' key. If plugins are loaded but this key + // is missing, plugin strings have not been registered yet. + if (Array.isArray(pluginsData) && pluginsData.length > 0 && getString('NEWDEV_display_name') === '') return false; + return true; }; // Call the function to execute the code diff --git a/front/js/cache.js b/front/js/cache.js index 575a40d4..4412a622 100644 --- a/front/js/cache.js +++ b/front/js/cache.js @@ -176,11 +176,31 @@ function cacheApiConfig() { }); } +// Module-level declaration so cachePluginStrings() and other callers can +// safely reference pluginsData even before cacheSettings() has fetched it. +// Populated (or repopulated) by cacheSettings() on every full fetch. +var pluginsData = []; + function cacheSettings() { return new Promise((resolve, reject) => { if(getCache(CACHE_KEYS.initFlag('cacheSettings')) === "true") { + // Re-populate pluginsData only when cachePluginStrings hasn't completed + // yet (first load after a deploy / migration). Once cachePluginStrings_v1 + // is set it early-returns immediately and never reads pluginsData again, + // so the extra fetch would be pointless on every subsequent warm load. + if (getCache(CACHE_KEYS.initFlag('cachePluginStrings_v1')) !== 'true') { + fetchJson('plugins.json') + .catch(() => []) + .then((pluginsArr) => { + if (Array.isArray(pluginsArr) && pluginsArr.length > 0) { + pluginsData = pluginsArr; + } + resolve(); + }); + return; + } resolve(); return; } @@ -292,22 +312,9 @@ function cacheStrings() { return new Promise((resolve, reject) => { if(getCache(CACHE_KEYS.initFlag('cacheStrings_v2')) === "true") { - // Core strings are cached, but plugin strings may have failed silently on - // the first load (non-fatal fetch). Always re-fetch them so that plugin - // keys like "CSVBCKP_overwrite_description" are available without needing - // a full clearCache(). - fetchJson('table_plugins_language_strings.json') - .catch((pluginError) => { - console.warn('[cacheStrings early-return] Plugin language strings unavailable (non-fatal):', pluginError); - return []; - }) - .then((data) => { - if (!Array.isArray(data)) { data = []; } - data.forEach((langString) => { - setCache(CACHE_KEYS.langString(langString.stringKey, langString.languageCode), langString.stringValue); - }); - resolve(); - }); + // Core strings are already cached. Plugin strings are now handled by + // the dedicated cachePluginStrings() step — nothing to do here. + resolve(); return; } @@ -321,42 +328,21 @@ function cacheStrings() { languagesToLoad.push(additionalLanguage) } - console.log(languagesToLoad); + console.log('[cacheStrings] Languages to load:', languagesToLoad); const languagePromises = languagesToLoad.map((language_code) => { return new Promise((resolveLang, rejectLang) => { - // Fetch core strings and translations - $.get(`php/templates/language/${language_code}.json?nocache=${Date.now()}`) .done((res) => { - // Iterate over each key-value pair and store the translations - Object.entries(res).forEach(([key, value]) => { + const entries = Object.entries(res); + entries.forEach(([key, value]) => { setCache(CACHE_KEYS.langString(key, language_code), value); }); - - // Fetch strings and translations from plugins (non-fatal — file may - // not exist on first boot or immediately after a cache clear) - fetchJson('table_plugins_language_strings.json') - .catch((pluginError) => { - console.warn('[cacheStrings] Plugin language strings unavailable (non-fatal):', pluginError); - return []; // treat as empty list - }) - .then((data) => { - // Defensive: ensure data is an array (fetchJson may return - // an object, undefined, or empty string on edge cases) - if (!Array.isArray(data)) { data = []; } - // Store plugin translations - data.forEach((langString) => { - setCache(CACHE_KEYS.langString(langString.stringKey, langString.languageCode), langString.stringValue); - }); - - // Handle successful completion of language processing - handleSuccess('cacheStrings_v2'); - resolveLang(); - }); + console.log(`[cacheStrings] Loaded ${entries.length} core strings for language '${language_code}'.`); + handleSuccess('cacheStrings_v2'); + resolveLang(); }) .fail((error) => { - // Handle failure in core strings fetching rejectLang(error); }); }); @@ -365,11 +351,9 @@ function cacheStrings() { // Wait for all language promises to complete Promise.all(languagePromises) .then(() => { - // All languages processed successfully resolve(); }) .catch((error) => { - // Handle failure in any of the language processing handleFailure('cacheStrings_v2'); reject(error); }); @@ -377,6 +361,53 @@ function cacheStrings() { }); } +// ----------------------------------------------------------------------------- +// Fetch and cache plugin-specific language strings from table_plugins_language_strings.json. +// Runs as a separate init step so that a missing or empty file triggers a retry +// via retryStep(), instead of silently marking initialization complete. +// Resolves immediately if no plugins are loaded (pluginsData is empty). +// ----------------------------------------------------------------------------- +function cachePluginStrings() { + return new Promise((resolve, reject) => { + if (getCache(CACHE_KEYS.initFlag('cachePluginStrings_v1')) === 'true') { + resolve(); + return; + } + + fetchJson('table_plugins_language_strings.json') + .catch((err) => { + console.warn('[cachePluginStrings] Plugin language strings fetch failed:', err); + return []; + }) + .then((data) => { + if (!Array.isArray(data)) { data = []; } + + if (data.length === 0) { + // No plugin strings returned. If plugins are loaded the file may not + // be ready yet — reject so retryStep() retries. If no plugins are + // loaded at all, empty strings are expected: succeed. + const hasPlugins = Array.isArray(pluginsData) && pluginsData.length > 0; + if (hasPlugins) { + console.warn('[cachePluginStrings] 0 entries returned but plugins are loaded — file may not be ready yet. Will retry.'); + reject(new Error('Plugin language strings empty')); + } else { + console.log('[cachePluginStrings] No plugins loaded — skipping plugin string cache.'); + handleSuccess('cachePluginStrings_v1'); + resolve(); + } + return; + } + + data.forEach((langString) => { + setCache(CACHE_KEYS.langString(langString.stringKey, langString.languageCode), langString.stringValue); + }); + console.log(`[cachePluginStrings] Loaded ${data.length} plugin language string entries.`); + handleSuccess('cachePluginStrings_v1'); + resolve(); + }); + }); +} + // ----------------------------------------------------------------------------- // Get translated language string function getString(key) { diff --git a/front/multiEditCore.php b/front/multiEditCore.php index 2e447431..b7933eb7 100755 --- a/front/multiEditCore.php +++ b/front/multiEditCore.php @@ -537,7 +537,9 @@ function deleteSelectedDevices() } -getData(); +// Wait for full app init (strings + plugin strings) before rendering. +// Without this guard getString() returns undefined during cold init. +callAfterAppInitialized(getData); diff --git a/front/pluginsCore.php b/front/pluginsCore.php index 43fe13f0..70ca8322 100755 --- a/front/pluginsCore.php +++ b/front/pluginsCore.php @@ -80,13 +80,16 @@ function initFields() { // Update the lastMac so we don't reload unnecessarily lastMac = currentVal; - // Trigger data loading based on new MAC - getData(); + // Wait for full app init (strings + plugin strings) before rendering tabs. + // Without this guard getString() returns undefined during cold init. + callAfterAppInitialized(getData); } else if((currentVal === "--" || currentVal == null ) && keepUpdating) { $("#txtMacFilter").val("--"); // need to set this as filters are using this later on keepUpdating = false; // stop updates - getData(); + // Wait for full app init (strings + plugin strings) before rendering tabs. + // Without this guard getString() returns undefined during cold init. + callAfterAppInitialized(getData); } } @@ -289,7 +292,7 @@ async function getData() { showSpinner(); console.log("Plugins getData called"); - const plugins = await fetchJson('plugins.json'); + const plugins = await fetchPluginJson('plugins.json'); pluginDefinitions = plugins.data; // Fetch counts BEFORE rendering tabs so we can skip empty plugins (no flicker). @@ -305,7 +308,10 @@ async function getData() { } } -async function fetchJson(filename) { +// NOTE: Named fetchPluginJson (not fetchJson) to avoid colliding with the +// module-level fetchJson defined in cache.js, which returns res['data'] and +// is used by cachePluginStrings() during app initialization. +async function fetchPluginJson(filename) { const response = await fetch(`php/server/query_json.php?file=${filename}`); if (!response.ok) throw new Error(`Failed to load ${filename}`); return await response.json(); @@ -381,7 +387,7 @@ async function fetchPluginCounts(prefixes) { if (!foreignKey) { // ---- FAST PATH: pre-computed static JSON ---- - const stats = await fetchJson('table_plugins_stats.json'); + const stats = await fetchPluginJson('table_plugins_stats.json'); rows = stats.data; } else { // ---- MAC-FILTERED PATH: single SQL via REST endpoint ---- diff --git a/front/workflowsCore.php b/front/workflowsCore.php index 12b9c0ec..b5a4516b 100755 --- a/front/workflowsCore.php +++ b/front/workflowsCore.php @@ -1374,7 +1374,9 @@ $(document).ready(function() { // -------------------------------------- // Initialize $(document).ready(function () { - getData(); + // Wait for full app init (strings + plugin strings) before rendering. + // Without this guard getString() returns undefined during cold init. + callAfterAppInitialized(getData); }); function hideWorkflowsSkeleton() { diff --git a/test/ui/test_ui_translations.py b/test/ui/test_ui_translations.py new file mode 100644 index 00000000..7bbca4cf --- /dev/null +++ b/test/ui/test_ui_translations.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +""" +Translation Loading UI Tests + +Validates that both core and plugin translation strings are available after +page load and after a browser cache clear, as described in: +PRD: Reliable Loading of Plugin Translation Strings +""" + +import sys +import os +import time + +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait + +sys.path.insert(0, os.path.dirname(__file__)) + +from .test_helpers import BASE_URL, wait_for_page_load # noqa: E402 + +# JS helper: poll isAppInitialized() until true (or timeout) +_WAIT_FOR_INIT_JS = "return typeof isAppInitialized === 'function' && isAppInitialized();" + +# Timeout (seconds) for app init — plugins + core strings must all be loaded +INIT_TIMEOUT = 30 + + +def _wait_for_app_init(driver, timeout=INIT_TIMEOUT): + """Block until isAppInitialized() returns true in the browser.""" + WebDriverWait(driver, timeout).until( + lambda d: d.execute_script(_WAIT_FOR_INIT_JS) is True + ) + + +def _get_string(driver, key): + """Call getString(key) in the browser and return the result.""" + return driver.execute_script( + "return (typeof getString === 'function') ? getString(arguments[0]) : null;", + key + ) + + +def _get_local_storage(driver, key): + """Return the raw localStorage value for a given key.""" + return driver.execute_script("return localStorage.getItem(arguments[0]);", key) + + +def _clear_local_storage(driver): + """Clear the browser's localStorage (simulates a cache clear).""" + driver.execute_script("localStorage.clear(); sessionStorage.clear();") + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +def test_plugin_strings_loaded_on_cold_init(driver): + """After a localStorage clear + reload, plugin strings must be available. + + Covers PRD Scenario 2 (Cache Clear) and the core bug: + cachePluginStrings() must not silently succeed with empty data. + """ + driver.get(f"{BASE_URL}/index.php") + wait_for_page_load(driver, timeout=10) + + # Simulate a cache clear + _clear_local_storage(driver) + + # Reload so executeOnce() runs fresh with empty localStorage + driver.refresh() + wait_for_page_load(driver, timeout=10) + + # Wait for full app initialization including cachePluginStrings_v1 + _wait_for_app_init(driver) + + # NEWDEV is a necessary plugin — its display_name key is always generated + plugin_string = _get_string(driver, "NEWDEV_display_name") + assert plugin_string, ( + "Plugin string 'NEWDEV_display_name' should be non-empty after cold init. " + "cachePluginStrings() may have resolved with empty data." + ) + + +def test_core_strings_loaded_on_cold_init(driver): + """After a localStorage clear + reload, core strings must be available. + + Ensures cacheStrings() still works correctly after its simplification. + """ + driver.get(f"{BASE_URL}/index.php") + wait_for_page_load(driver, timeout=10) + + _clear_local_storage(driver) + driver.refresh() + wait_for_page_load(driver, timeout=10) + + _wait_for_app_init(driver) + + core_string = _get_string(driver, "UI_LANG_name") + assert core_string, ( + "Core string 'UI_LANG_name' should be non-empty after cold init. " + "cacheStrings() may have regressed." + ) + + +def test_plugin_strings_init_flag_set(driver): + """The cachePluginStrings_v1_completed flag must be set in localStorage after init. + + Ensures handleSuccess('cachePluginStrings_v1') was reached, confirming + plugin strings actually loaded rather than failing silently. + """ + driver.get(f"{BASE_URL}/index.php") + wait_for_page_load(driver, timeout=10) + + _clear_local_storage(driver) + driver.refresh() + wait_for_page_load(driver, timeout=10) + + _wait_for_app_init(driver) + + flag = _get_local_storage(driver, "cachePluginStrings_v1_completed") + assert flag == "true", ( + "localStorage key 'cachePluginStrings_v1_completed' should be 'true' after init. " + f"Got: {flag!r}" + ) + + +def test_plugin_strings_available_on_warm_reload(driver): + """On a normal page refresh (warm cache), plugin strings remain available. + + Covers PRD Scenario 1 (Normal Refresh) and Scenario 3 (Repeated Refreshes). + """ + driver.get(f"{BASE_URL}/index.php") + wait_for_page_load(driver, timeout=10) + _wait_for_app_init(driver) + + # Warm reload — localStorage is intact + driver.refresh() + wait_for_page_load(driver, timeout=10) + _wait_for_app_init(driver) + + plugin_string = _get_string(driver, "NEWDEV_display_name") + assert plugin_string, ( + "Plugin string 'NEWDEV_display_name' should be non-empty after a warm reload." + ) + + +def test_plugins_page_no_undefined_strings_after_cache_clear(driver): + """pluginsCore.php must not render 'undefined' tab labels after a cache clear. + + Previously initFields() called getData() before isAppInitialized() was + true, so getString() returned undefined and tab headers showed 'undefined'. + This regression test loads plugins.php after a cold init and asserts that + no visible text on the page is the literal string 'undefined'. + """ + driver.get(f"{BASE_URL}/index.php") + wait_for_page_load(driver, timeout=10) + + _clear_local_storage(driver) + driver.get(f"{BASE_URL}/plugins.php") + wait_for_page_load(driver, timeout=15) + + _wait_for_app_init(driver) + + # Give the tabs a moment to render after init completes + time.sleep(2) + + # Allow the word 'undefined' inside code/technical blocks but not as a + # standalone visible label replacing a translation string. + # We check that it does not appear as a trimmed standalone word in headings/tabs. + tab_labels = driver.find_elements(By.CSS_SELECTOR, "#tabs-location a, .tab-label, h5") + for el in tab_labels: + label = el.text.strip() + assert label != "undefined", ( + f"Tab/heading label is literally 'undefined' — plugin strings were not " + f"loaded before getData() rendered the tabs on plugins.php." + ) From cca6d041e5c128f4465d2083f6f1322a40486f53 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Thu, 2 Jul 2026 14:16:45 +1000 Subject: [PATCH 03/11] FE: css + readme fixes --- README.md | 2 +- front/css/app.css | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7afa9ba1..ec9629ca 100755 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![Docker Size](https://img.shields.io/docker/image-size/jokobsk/netalertx?label=Size&logo=Docker&color=0aa8d2&logoColor=fff&style=for-the-badge)](https://hub.docker.com/r/jokobsk/netalertx) [![Docker Pulls](https://img.shields.io/docker/pulls/jokobsk/netalertx?label=Pulls&logo=docker&color=0aa8d2&logoColor=fff&style=for-the-badge)](https://hub.docker.com/r/jokobsk/netalertx) -[![GitHub Release](https://img.shields.io/github/v/release/netalertx/NetAlertX?color=0aa8d2&logoColor=fff&logo=GitHub&style=for-the-badge)](https://github.com/netalertx/NetAlertX/releases) +[![GitHub Release](https://img.shields.io/github/v/release/netalertx/NetAlertX?color=0aa8d2&logoColor=fff&logo=GitHub&style=for-the-badge&label=latest)](https://github.com/netalertx/NetAlertX/releases) [![Discord](https://img.shields.io/discord/1274490466481602755?color=0aa8d2&logoColor=fff&logo=Discord&style=for-the-badge)](https://discord.gg/NczTUTWyRr) [![Home Assistant](https://img.shields.io/badge/Repo-blue?logo=home-assistant&style=for-the-badge&color=0aa8d2&logoColor=fff&label=Add)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Falexbelgium%2Fhassio-addons) diff --git a/front/css/app.css b/front/css/app.css index e16828de..77232e3a 100755 --- a/front/css/app.css +++ b/front/css/app.css @@ -317,6 +317,10 @@ body width: 150px; } +.fixed .main-header +{ + z-index: 10000; +} .navbar-nav > .user-menu .user-image { From dae8b79d02dccbc12b601234ea4c604a74ae494f Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Thu, 2 Jul 2026 14:30:15 +1000 Subject: [PATCH 04/11] FE: css fixes --- front/css/app.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/front/css/app.css b/front/css/app.css index 77232e3a..7375d503 100755 --- a/front/css/app.css +++ b/front/css/app.css @@ -247,7 +247,8 @@ a[target="_blank"] { .main-footer { padding: 5px; - color: gray; + z-index: 10000; + position: inherit; } .header-server-time @@ -319,7 +320,7 @@ body .fixed .main-header { - z-index: 10000; + z-index: 10001; } .navbar-nav > .user-menu .user-image @@ -334,6 +335,7 @@ body .main-sidebar, .left-side { width: 150px; + z-index: 10000; } .content-wrapper, From 35b19cbb51771393316b985b61fa4450acb5b4fc Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:32:01 +0000 Subject: [PATCH 05/11] Refactor cache handling for plugins: update pluginsData initialization and improve fetch logic; enhance UI tests for translation loading --- front/js/cache.js | 39 +++++++++++++++++++++++++-------- front/pluginsCore.php | 2 +- test/ui/test_ui_translations.py | 18 ++++++++------- 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/front/js/cache.js b/front/js/cache.js index 4412a622..9d6e9539 100644 --- a/front/js/cache.js +++ b/front/js/cache.js @@ -178,8 +178,10 @@ function cacheApiConfig() { // Module-level declaration so cachePluginStrings() and other callers can // safely reference pluginsData even before cacheSettings() has fetched it. -// Populated (or repopulated) by cacheSettings() on every full fetch. -var pluginsData = []; +// null = fetch not yet attempted or fetch failed (unknown state). +// [] = fetch succeeded and confirmed no plugins are configured. +// [...] = fetch succeeded and plugins are present. +var pluginsData = null; function cacheSettings() { @@ -192,12 +194,18 @@ function cacheSettings() // so the extra fetch would be pointless on every subsequent warm load. if (getCache(CACHE_KEYS.initFlag('cachePluginStrings_v1')) !== 'true') { fetchJson('plugins.json') - .catch(() => []) .then((pluginsArr) => { - if (Array.isArray(pluginsArr) && pluginsArr.length > 0) { + // Only update pluginsData on a successful fetch so that a network + // failure is not misread as "no plugins configured" (null sentinel). + if (Array.isArray(pluginsArr)) { pluginsData = pluginsArr; } resolve(); + }) + .catch(() => { + // Fetch failed — leave pluginsData as null (unknown state) so + // cachePluginStrings() retries rather than treating it as no-op. + resolve(); }); return; } @@ -312,10 +320,20 @@ function cacheStrings() { return new Promise((resolve, reject) => { if(getCache(CACHE_KEYS.initFlag('cacheStrings_v2')) === "true") { - // Core strings are already cached. Plugin strings are now handled by - // the dedicated cachePluginStrings() step — nothing to do here. - resolve(); - return; + // Verify STRINGS_COUNT still matches the current language configuration. + // A language switch without a full cache clear leaves the flag set but + // the count wrong, causing isAppInitialized() to deadlock. + const expectedCount = getLangCode() === 'en_us' ? 1 : 2; + const currentCount = parseInt(getCache(CACHE_KEYS.STRINGS_COUNT)) || 0; + if (currentCount === expectedCount) { + resolve(); + return; + } + // Mismatch — reset count and fall through to reload strings for the + // current language set. The flag stays set; handleSuccess re-increments. + console.log(`[cacheStrings] STRINGS_COUNT mismatch (stored: ${currentCount}, expected: ${expectedCount}) — reloading strings.`); + setCache(CACHE_KEYS.STRINGS_COUNT, 0); + // Do NOT return — fall through to full load path below. } // Create a promise for each language (include en_us by default as fallback) @@ -386,7 +404,10 @@ function cachePluginStrings() { // No plugin strings returned. If plugins are loaded the file may not // be ready yet — reject so retryStep() retries. If no plugins are // loaded at all, empty strings are expected: succeed. - const hasPlugins = Array.isArray(pluginsData) && pluginsData.length > 0; + // null = plugins.json fetch failed (unknown) — retry to be safe. + // [] = confirmed no plugins — accept empty strings as valid. + // [...] = plugins present — retry, file may not be ready yet. + const hasPlugins = pluginsData === null || (Array.isArray(pluginsData) && pluginsData.length > 0); if (hasPlugins) { console.warn('[cachePluginStrings] 0 entries returned but plugins are loaded — file may not be ready yet. Will retry.'); reject(new Error('Plugin language strings empty')); diff --git a/front/pluginsCore.php b/front/pluginsCore.php index 70ca8322..a8f36686 100755 --- a/front/pluginsCore.php +++ b/front/pluginsCore.php @@ -312,7 +312,7 @@ async function getData() { // module-level fetchJson defined in cache.js, which returns res['data'] and // is used by cachePluginStrings() during app initialization. async function fetchPluginJson(filename) { - const response = await fetch(`php/server/query_json.php?file=${filename}`); + const response = await fetch(`php/server/query_json.php?file=${encodeURIComponent(filename)}&nocache=${Date.now()}`); if (!response.ok) throw new Error(`Failed to load ${filename}`); return await response.json(); } diff --git a/test/ui/test_ui_translations.py b/test/ui/test_ui_translations.py index 7bbca4cf..aa775e9d 100644 --- a/test/ui/test_ui_translations.py +++ b/test/ui/test_ui_translations.py @@ -9,7 +9,6 @@ PRD: Reliable Loading of Plugin Translation Strings import sys import os -import time from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait @@ -161,16 +160,19 @@ def test_plugins_page_no_undefined_strings_after_cache_clear(driver): _wait_for_app_init(driver) - # Give the tabs a moment to render after init completes - time.sleep(2) + # Wait for getData() to complete: either tab links or the empty-state + # paragraph will appear once generateTabs() has run. + WebDriverWait(driver, 10).until( + lambda d: len(d.find_elements( + By.CSS_SELECTOR, + "#tabs-location a, #tabs-content-location p.text-muted" + )) > 0 + ) - # Allow the word 'undefined' inside code/technical blocks but not as a - # standalone visible label replacing a translation string. - # We check that it does not appear as a trimmed standalone word in headings/tabs. tab_labels = driver.find_elements(By.CSS_SELECTOR, "#tabs-location a, .tab-label, h5") for el in tab_labels: label = el.text.strip() assert label != "undefined", ( - f"Tab/heading label is literally 'undefined' — plugin strings were not " - f"loaded before getData() rendered the tabs on plugins.php." + "Tab/heading label is literally 'undefined' — plugin strings were not " + "loaded before getData() rendered the tabs on plugins.php." ) From 1a50a502cb05d617ef768a65e6a63827dc1837ff Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:45:27 +0000 Subject: [PATCH 06/11] Enhance cacheStrings function: add language tracking for string cache validation; improve stale cache detection logic --- front/js/cache.js | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/front/js/cache.js b/front/js/cache.js index 9d6e9539..dcae0d18 100644 --- a/front/js/cache.js +++ b/front/js/cache.js @@ -50,6 +50,7 @@ const CACHE_KEYS = { // --- Internal init tracking --- GRAPHQL_STARTED: 'graphQLServerStarted', // set when GraphQL server responds STRINGS_COUNT: 'cacheStringsCountCompleted', // count of language packs loaded + STRINGS_LANG: 'cacheStrings_v2_language', // active non-English locale at last load COMPLETED_CALLS: 'completedCalls', // comma-joined list of completed init calls INIT_TIMESTAMP: 'nax_init_timestamp', // ms timestamp of last successful cache init CACHE_VERSION: 'nax_cache_version', // version stamp for auto-bust on deploy @@ -320,18 +321,21 @@ function cacheStrings() { return new Promise((resolve, reject) => { if(getCache(CACHE_KEYS.initFlag('cacheStrings_v2')) === "true") { - // Verify STRINGS_COUNT still matches the current language configuration. - // A language switch without a full cache clear leaves the flag set but - // the count wrong, causing isAppInitialized() to deadlock. - const expectedCount = getLangCode() === 'en_us' ? 1 : 2; - const currentCount = parseInt(getCache(CACHE_KEYS.STRINGS_COUNT)) || 0; - if (currentCount === expectedCount) { + // Verify STRINGS_COUNT and the stored language both match the current + // configuration. A count-only check allows a locale switch between two + // non-English languages (both count=2) to pass undetected, leaving the + // UI with stale strings for the old language. + const activeLang = getLangCode(); + const expectedCount = activeLang === 'en_us' ? 1 : 2; + const currentCount = parseInt(getCache(CACHE_KEYS.STRINGS_COUNT)) || 0; + const storedLang = getCache(CACHE_KEYS.STRINGS_LANG) || 'en_us'; + if (currentCount === expectedCount && storedLang === activeLang) { resolve(); return; } // Mismatch — reset count and fall through to reload strings for the // current language set. The flag stays set; handleSuccess re-increments. - console.log(`[cacheStrings] STRINGS_COUNT mismatch (stored: ${currentCount}, expected: ${expectedCount}) — reloading strings.`); + console.log(`[cacheStrings] stale cache (count: ${currentCount}/${expectedCount}, lang: '${storedLang}' → '${activeLang}') — reloading strings.`); setCache(CACHE_KEYS.STRINGS_COUNT, 0); // Do NOT return — fall through to full load path below. } @@ -369,6 +373,9 @@ function cacheStrings() { // Wait for all language promises to complete Promise.all(languagePromises) .then(() => { + // Record the active language so the early-return check can detect + // a locale switch between two non-English languages (both count=2). + setCache(CACHE_KEYS.STRINGS_LANG, getLangCode()); resolve(); }) .catch((error) => { From 278ebe0be74d4fcb299f83025311d654803970b2 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Thu, 2 Jul 2026 14:49:52 +1000 Subject: [PATCH 07/11] FE: css + readme fixes --- README.md | 8 +++----- front/css/app.css | 1 - 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ec9629ca..fd2b6eec 100755 --- a/README.md +++ b/README.md @@ -21,13 +21,11 @@ -Centralized network visibility and continuous asset discovery for homelabs, IT teams, MSPs, and distributed environments. +NetAlertX is a network visibility and asset intelligence platform for homelabs, IT teams, MSPs, and distributed environments. It provides centralized network visibility and continuous asset discovery across remote sites, VLANs, branch offices, and segmented networks from a single interface. -Monitor devices, detect change, and maintain visibility across remote sites, VLANs, branch offices, and segmented networks from a single interface. +NetAlertX gives you a real-time source of truth for connected devices, helps identify shadow IT and unauthorized hardware, supports compliance initiatives, and automates operational workflows across distributed customer environments. -NetAlertX provides a centralized "Source of Truth" (NSoT) for network infrastructure. Maintain a real-time inventory of connected devices, identify Shadow IT and unauthorized hardware, support compliance initiatives, and automate operational workflows across distributed customer environments. - -Designed to bridge the gap between simple network scanners and complex SIEM platforms, NetAlertX delivers actionable network intelligence and centralized monitoring without the operational overhead. +Use NetAlertX to spot shadow IT, unauthorized hardware, IPAM drift, and other changes that matter to service teams. With multi-site sync, reporting, workflows, and webhooks, it helps MSPs stay ahead of problems without the overhead of a full NMS or SIEM. ## Table of Contents diff --git a/front/css/app.css b/front/css/app.css index 7375d503..a8dda9fa 100755 --- a/front/css/app.css +++ b/front/css/app.css @@ -344,7 +344,6 @@ body margin-left: 150px; } - @media (max-width: 767px) { .main-header .logo { width: 100%; From f57befde66b558688bb3c558a7c69de8c2bb4d6f Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Thu, 2 Jul 2026 14:53:30 +1000 Subject: [PATCH 08/11] FE: css fixes --- front/css/app.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/css/app.css b/front/css/app.css index a8dda9fa..06ea41b5 100755 --- a/front/css/app.css +++ b/front/css/app.css @@ -1851,7 +1851,7 @@ textarea[readonly], #modal-ok { - z-index: 1051; /*highest priority*/ + z-index: 11051; /*highest priority*/ } #modal-form-plc From 10e031650ad3a9c1dba7c5977685b478aaff82c0 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Sat, 4 Jul 2026 07:57:09 +1000 Subject: [PATCH 09/11] PLG+FE: css and sync PUSH devices fix #1698 --- front/css/app.css | 5 +++++ front/plugins/sync/sync.py | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/front/css/app.css b/front/css/app.css index 06ea41b5..b851c5ed 100755 --- a/front/css/app.css +++ b/front/css/app.css @@ -732,6 +732,11 @@ hr margin-right: auto; } +.modal +{ + z-index: 12000; +} + .modal_green { color: #258744; diff --git a/front/plugins/sync/sync.py b/front/plugins/sync/sync.py index 86ee4534..61c25578 100755 --- a/front/plugins/sync/sync.py +++ b/front/plugins/sync/sync.py @@ -106,7 +106,8 @@ def main(): # PUSHING/SENDING devices if send_devices: - file_path = f"{INSTALL_PATH}/api/table_devices.json" + api_path = os.environ.get('NETALERTX_API', '/tmp/api') + file_path = f"{api_path}/table_devices.json" pref = 'SYNC' if os.path.exists(file_path): @@ -116,6 +117,8 @@ def main(): mylog('verbose', [f'[{pluginName}] Sending file_content: "{file_content}"']) send_data(api_token, file_content, encryption_key, file_path, node_name, pref, hub_url) + else: + mylog('none', [f'[{pluginName}] ERROR Could not open: "{file_content}"']) else: mylog('verbose', [f'[{pluginName}] SYNC_hub_url not defined, skipping posting "Devices" data']) else: From 746b3d4c848cc18bce5fb032ea9ce698bff54700 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Sat, 4 Jul 2026 08:10:01 +1000 Subject: [PATCH 10/11] PLG: sync PUSH devices fix #1698 --- front/plugins/sync/sync.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/front/plugins/sync/sync.py b/front/plugins/sync/sync.py index 61c25578..9ccb5763 100755 --- a/front/plugins/sync/sync.py +++ b/front/plugins/sync/sync.py @@ -23,6 +23,7 @@ from messaging.in_app import write_notification # noqa: E402 [flake8 lint suppr import conf # noqa: E402 [flake8 lint suppression] from pytz import timezone # noqa: E402 [flake8 lint suppression] from database import get_temp_db_connection # noqa: E402 [flake8 lint suppression] +from config_paths import API_PATH # noqa: E402 [flake8 lint suppression] # Make sure the TIMEZONE for logging is correct conf.tz = timezone(get_setting_value('TIMEZONE')) @@ -105,9 +106,7 @@ def main(): # PUSHING/SENDING devices if send_devices: - - api_path = os.environ.get('NETALERTX_API', '/tmp/api') - file_path = f"{api_path}/table_devices.json" + file_path = f"{API_PATH}/table_devices.json" pref = 'SYNC' if os.path.exists(file_path): @@ -118,7 +117,7 @@ def main(): mylog('verbose', [f'[{pluginName}] Sending file_content: "{file_content}"']) send_data(api_token, file_content, encryption_key, file_path, node_name, pref, hub_url) else: - mylog('none', [f'[{pluginName}] ERROR Could not open: "{file_content}"']) + mylog('none', [f'[{pluginName}] ERROR Could not open: "{file_path}"']) else: mylog('verbose', [f'[{pluginName}] SYNC_hub_url not defined, skipping posting "Devices" data']) else: From 351b6a7d2837ac6030da5a5adc8707fd8230ae79 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Sat, 4 Jul 2026 09:36:24 +1000 Subject: [PATCH 11/11] BE: unable to exclude loacl_MAC entries #1700 --- server/scan/device_handling.py | 2 +- server/scan/session_events.py | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/server/scan/device_handling.py b/server/scan/device_handling.py index 90275f00..d0105b70 100755 --- a/server/scan/device_handling.py +++ b/server/scan/device_handling.py @@ -510,7 +510,7 @@ def update_vendors_from_mac(db): # ------------------------------------------------------------------------------- -def save_scanned_devices(db): +def save_own_device(db): sql = db.sql # TO-DO # Add Local MAC of default local interface diff --git a/server/scan/session_events.py b/server/scan/session_events.py index ca35c1fe..77f5f0a8 100755 --- a/server/scan/session_events.py +++ b/server/scan/session_events.py @@ -1,7 +1,7 @@ from scan.device_handling import ( create_new_devices, print_scan_stats, - save_scanned_devices, + save_own_device, exclude_ignored_devices, update_devices_data_from_scan, update_sync_hub_node, @@ -35,14 +35,15 @@ Logger(get_setting_value("LOG_LEVEL")) def process_scan(db): + + # Save own device data into CurrentScan TODO:move potentially into a separate plugin + mylog("verbose", "[Process Scan] Processing scan results") + save_own_device(db) + # Apply exclusions mylog("verbose", "[Process Scan] Exclude ignored devices") exclude_ignored_devices(db) - # Load current scan data - mylog("verbose", "[Process Scan] Processing scan results") - save_scanned_devices(db) - db.commitDB() # Print stats