Merge pull request #1697 from netalertx/next_release

Next release
This commit is contained in:
Jokob @NetAlertX
2026-07-04 11:03:09 +10:00
committed by GitHub
12 changed files with 344 additions and 81 deletions

View File

@@ -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)
@@ -21,13 +21,11 @@
</details>
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

View File

@@ -247,7 +247,8 @@ a[target="_blank"] {
.main-footer {
padding: 5px;
color: gray;
z-index: 10000;
position: inherit;
}
.header-server-time
@@ -317,6 +318,10 @@ body
width: 150px;
}
.fixed .main-header
{
z-index: 10001;
}
.navbar-nav > .user-menu .user-image
{
@@ -330,6 +335,7 @@ body
.main-sidebar,
.left-side {
width: 150px;
z-index: 10000;
}
.content-wrapper,
@@ -338,7 +344,6 @@ body
margin-left: 150px;
}
@media (max-width: 767px) {
.main-header .logo {
width: 100%;
@@ -727,6 +732,11 @@ hr
margin-right: auto;
}
.modal
{
z-index: 12000;
}
.modal_green
{
color: #258744;
@@ -1846,7 +1856,7 @@ textarea[readonly],
#modal-ok
{
z-index: 1051; /*highest priority*/
z-index: 11051; /*highest priority*/
}
#modal-form-plc

View File

@@ -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

View File

@@ -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
@@ -176,11 +177,39 @@ function cacheApiConfig() {
});
}
// Module-level declaration so cachePluginStrings() and other callers can
// safely reference pluginsData even before cacheSettings() has fetched it.
// 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()
{
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')
.then((pluginsArr) => {
// 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;
}
resolve();
return;
}
@@ -292,23 +321,23 @@ 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();
});
return;
// 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] 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.
}
// Create a promise for each language (include en_us by default as fallback)
@@ -321,42 +350,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 +373,12 @@ function cacheStrings() {
// Wait for all language promises to complete
Promise.all(languagePromises)
.then(() => {
// All languages processed successfully
// 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) => {
// Handle failure in any of the language processing
handleFailure('cacheStrings_v2');
reject(error);
});
@@ -377,6 +386,56 @@ 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.
// 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'));
} 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) {

View File

@@ -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);
</script>

View File

@@ -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,8 +106,7 @@ def main():
# PUSHING/SENDING devices
if send_devices:
file_path = f"{INSTALL_PATH}/api/table_devices.json"
file_path = f"{API_PATH}/table_devices.json"
pref = 'SYNC'
if os.path.exists(file_path):
@@ -116,6 +116,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_path}"'])
else:
mylog('verbose', [f'[{pluginName}] SYNC_hub_url not defined, skipping posting "Devices" data'])
else:

View File

@@ -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,8 +308,11 @@ async function getData() {
}
}
async function fetchJson(filename) {
const response = await fetch(`php/server/query_json.php?file=${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=${encodeURIComponent(filename)}&nocache=${Date.now()}`);
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 ----

View File

@@ -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() {

View File

@@ -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}")

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,178 @@
#!/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
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)
# 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
)
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", (
"Tab/heading label is literally 'undefined' — plugin strings were not "
"loaded before getData() rendered the tabs on plugins.php."
)