diff --git a/docs/DEVICE_MANAGEMENT.md b/docs/DEVICE_MANAGEMENT.md
index 1f2616e05..e17c8d6a6 100755
--- a/docs/DEVICE_MANAGEMENT.md
+++ b/docs/DEVICE_MANAGEMENT.md
@@ -4,15 +4,15 @@ The Main Info section is where most of the device identifiable information is st
> [!NOTE]
>
-> You can multi-edit devices by selecting them in the main Devices view, from the Mainetence section, or via the CSV Export functionality under Maintenance. More info can be found in the [Devices Bulk-editing docs](./DEVICES_BULK_EDITING.md).
+> You can multi-edit devices by selecting them in the main Devices view, from the Maintenance section, or via the CSV Export functionality under Maintenance. More info can be found in the [Devices Bulk-editing docs](./DEVICES_BULK_EDITING.md).

## Main Info
- - **MAC**: MAC addres of the device. Not editable, unless creating a new dummy device.
- - **Last IP**: IP addres of the device. Not editable, unless creating a new dummy device.
+ - **MAC**: MAC address of the device. Not editable, unless creating a new dummy device.
+ - **Last IP**: IP address of the device. Not editable, unless creating a new dummy device.
- **Name**: Friendly device name. Autodetected via various 🆎 Name discovery [plugins](https://docs.netalertx.com/PLUGINS_OVERVIEW). The app attaches `(IP match)` if the name is discovered via an IP match and not MAC match which could mean the name could be incorrect as IPs might change.
- **Icon**: Partially autodetected. Select an existing or [add a custom icon](./ICONS.md). You can also auto-apply the same icon on all devices of the same type.
- **Owner**: Device owner (The list is self-populated with existing owners and you can add custom values).
diff --git a/front/css/app.css b/front/css/app.css
index 739c47726..b83a15ecb 100755
--- a/front/css/app.css
+++ b/front/css/app.css
@@ -1944,6 +1944,8 @@ textarea[readonly],
#networkTree .box
{
+ /* Positioned HTML inside SVG foreignObject is misplaced by WebKit. */
+ position:static;
border-width:1px;
border-top-color:grey;
padding:0px;
@@ -1966,7 +1968,7 @@ textarea[readonly],
}
#networkTree .netNodeText
{
- position: absolute;
+ position: static;
}
#networkTree .netPort
{
@@ -1977,7 +1979,8 @@ textarea[readonly],
#networkTree .portBckgIcon
{
- opacity: 0.3;
+ color: var(--color-gray);
+ color: color-mix(in srgb, currentColor 30%, transparent);
display: initial;
float: left;
width: 1em;
@@ -2054,8 +2057,8 @@ textarea[readonly],
#networkTree .netCollapse
{
- position: absolute;
- right: 0;
+ position: static;
+ float: right;
margin-right: -3px;
}
@@ -2078,9 +2081,12 @@ textarea[readonly],
#networkTree .network-hw-icon
{
- position: absolute;
+ position: static;
+ display: inline-block;
margin-left: -0.4em;
- opacity: 0.3;
+ /* Alpha in the color avoids WebKit's foreignObject opacity layer bug. */
+ color: var(--color-gray);
+ color: color-mix(in srgb, currentColor 30%, transparent);
margin-top: 0.1em;
}
diff --git a/front/js/network-tree.js b/front/js/network-tree.js
index 26ebcce59..7dc6fcc83 100644
--- a/front/js/network-tree.js
+++ b/front/js/network-tree.js
@@ -281,7 +281,7 @@ function initTree(myHierarchy)
// generate +/- icon if node has children nodes
collapseExpandHtml = nodeData.data.hasChildren ?
`
diff --git a/front/php/templates/language/ru_ru.json b/front/php/templates/language/ru_ru.json
index edda373df..b658e2a39 100644
--- a/front/php/templates/language/ru_ru.json
+++ b/front/php/templates/language/ru_ru.json
@@ -233,8 +233,8 @@
"Device_Shortcut_OnlineChart": "Присутствие устройств",
"Device_Shortcut_Unstable": "Нестабильный",
"Device_TableHead_AlertDown": "Оповещение о сост. ВЫКЛ",
- "Device_TableHead_AlertEvents": "",
- "Device_TableHead_CanSleep": "",
+ "Device_TableHead_AlertEvents": "Оповещения о событиях",
+ "Device_TableHead_CanSleep": "Может спать",
"Device_TableHead_Comments": "Комментарии",
"Device_TableHead_Connected_Devices": "Соединения",
"Device_TableHead_CustomProps": "Свойства / Действия",
@@ -265,7 +265,7 @@
"Device_TableHead_Rowid": "ID строки",
"Device_TableHead_SSID": "SSID",
"Device_TableHead_SourcePlugin": "Исходный плагин",
- "Device_TableHead_StaticIP": "",
+ "Device_TableHead_StaticIP": "Статический IP",
"Device_TableHead_Status": "Статус",
"Device_TableHead_SyncHubNodeName": "Узел синхронизации",
"Device_TableHead_Type": "Тип",
@@ -341,7 +341,7 @@
"Gen_Copy": "Запустить",
"Gen_CopyToClipboard": "Копировать в буфер обмена",
"Gen_DataUpdatedUITakesTime": "ОК - Обновление UI может занять некоторое время, если сканирование выполняется.",
- "Gen_Day": "",
+ "Gen_Day": "День",
"Gen_Delete": "Удалить",
"Gen_DeleteAll": "Удалить все",
"Gen_Description": "Описание",
@@ -350,11 +350,11 @@
"Gen_Filter": "Фильтр",
"Gen_Flapping": "Нестабильный",
"Gen_Generate": "Генерировать",
- "Gen_Hour": "",
+ "Gen_Hour": "Час",
"Gen_InvalidMac": "Неверный Mac-адрес.",
"Gen_Invalid_Value": "Введено некорректное значение",
"Gen_LockedDB": "ОШИБКА - Возможно, база данных заблокирована. Проверьте инструменты разработчика F12 -> Консоль или повторите попытку позже.",
- "Gen_Month": "",
+ "Gen_Month": "Месяц",
"Gen_NetworkMask": "Маска сети",
"Gen_New": "Новый",
"Gen_Next": "Следующий",
@@ -386,7 +386,7 @@
"Gen_Update_Value": "Обновить значение",
"Gen_ValidIcon": "",
"Gen_Warning": "Предупреждение",
- "Gen_Week": "",
+ "Gen_Week": "Неделя",
"Gen_Work_In_Progress": "Работа продолжается, самое время оставить отзыв на https://github.com/netalertx/NetAlertX/issues",
"Gen_create_new_device": "Новое устройство",
"Gen_create_new_device_info": "Устройства обычно обнаруживаются с помощью плагинов. Однако в некоторых случаях вам может потребоваться добавить устройства вручную. Для изучения конкретных сценариев ознакомьтесь с документацией Remote Networks.",
@@ -716,14 +716,14 @@
"Systeminfo_Network_Server_Query": "Запрос сервера:",
"Systeminfo_Network_Server_Query_String": "Нет строки запроса",
"Systeminfo_Network_Server_Version": "Версия сервера:",
- "Systeminfo_Performance": "",
- "Systeminfo_Performance_CPU": "",
- "Systeminfo_Performance_Disabled": "",
- "Systeminfo_Performance_Duration": "",
- "Systeminfo_Performance_IO_Caption": "",
- "Systeminfo_Performance_IO_Read": "",
- "Systeminfo_Performance_IO_Write": "",
- "Systeminfo_Performance_Memory": "",
+ "Systeminfo_Performance": "Производительность",
+ "Systeminfo_Performance_CPU": "CPU %",
+ "Systeminfo_Performance_Disabled": "Сбор истории ресурсов в настоящее время отключен. Включите его в настройках.",
+ "Systeminfo_Performance_Duration": "Продолжительность сканирования (сек.)",
+ "Systeminfo_Performance_IO_Caption": "Только ввод/вывод прямого процесса NetAlertX - подпроцесс ввода/вывода плагина (например, сканер, записывающий свой собственный файл результатов) не включен.",
+ "Systeminfo_Performance_IO_Read": "Чтение ввода-вывода (Мб)",
+ "Systeminfo_Performance_IO_Write": "Запись ввода-вывода (Мб)",
+ "Systeminfo_Performance_Memory": "Использование памяти (Мб)",
"Systeminfo_Services": "Службы",
"Systeminfo_Services_Description": "Описание службы",
"Systeminfo_Services_Name": "Имя службы",
@@ -854,4 +854,4 @@
"settings_system_label": "Система",
"settings_update_item_warning": "Обновить значение ниже. Будьте осторожны, следуя предыдущему формату. Проверка не выполняется.",
"test_event_tooltip": "Сначала сохраните изменения, прежде чем проверять настройки."
-}
\ No newline at end of file
+}
diff --git a/test/ui/TESTING_GUIDE.md b/test/ui/TESTING_GUIDE.md
index 6afef9b1f..66632ab09 100644
--- a/test/ui/TESTING_GUIDE.md
+++ b/test/ui/TESTING_GUIDE.md
@@ -413,4 +413,24 @@ Tests that the settings form submits correctly to `php/server/util.php` with `fu
Verifies that all settings are preserved when saved (no data loss during save operation).
**Key Coverage**: Form submission flow → PHP `saveSettings()` → Config file generation with Python-compatible formatting
+# Network topology rendering in WebKit, Chromium and Firefox
+
+The standalone regression uses the real Treeviz bundle, network renderer and styles
+with four fixture devices. No running NetAlertX backend or database is required.
+It checks painted pixels, node clicks, collapse/expand, wheel zoom and drag pan.
+Pixel checks are necessary because WebKit may return correct DOM bounds while
+painting positioned or translucent HTML at the SVG origin instead.
+
+Run from the repository root (Node.js required):
+
+```sh
+npm install --prefix /tmp/netalertx-browser-tests playwright pngjs
+/tmp/netalertx-browser-tests/node_modules/.bin/playwright install --with-deps webkit chromium firefox
+NODE_PATH=/tmp/netalertx-browser-tests/node_modules node test/ui/test_network_topology.cjs
+```
+
+Screenshots are written to `/tmp/netalertx-topology`, or to the directory specified
+by `NETALERTX_TOPOLOGY_ARTIFACTS`. This regression runs separately from pytest.
+Also verify the fixture in native Safari when a Mac is available; Playwright
+WebKit is useful regression coverage but is not the Safari application.
diff --git a/test/ui/fixtures/network_topology.html b/test/ui/fixtures/network_topology.html
new file mode 100644
index 000000000..fe7a4bf90
--- /dev/null
+++ b/test/ui/fixtures/network_topology.html
@@ -0,0 +1,41 @@
+
+
+
+NetAlertX network topology regression fixture
+
+
+
+
+
+
+
Network topology — discussion #1379
+
+
+
+
+
+
diff --git a/test/ui/test_network_topology.cjs b/test/ui/test_network_topology.cjs
new file mode 100644
index 000000000..6ecc5053a
--- /dev/null
+++ b/test/ui/test_network_topology.cjs
@@ -0,0 +1,119 @@
+// Standalone browser regression for SVG foreignObject rendering; see TESTING_GUIDE.md.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const http = require('node:http');
+const os = require('node:os');
+const path = require('node:path');
+const {chromium, firefox, webkit} = require('playwright');
+const {PNG} = require('pngjs');
+
+/** Verify actual painted pixels, since WebKit can report correct bounds for misplaced HTML. */
+async function assertPainted(page, selector) {
+ const items = await page.locator(selector).all();
+ assert(items.length > 0, selector + ' is missing');
+ for (const item of items) {
+ const rect = await item.boundingBox();
+ assert(rect && rect.width > 0 && rect.height > 0, selector + ' has no bounds');
+ const png = PNG.sync.read(await page.screenshot({clip: {
+ x: Math.ceil(rect.x + 1), y: Math.ceil(rect.y + 2),
+ width: Math.max(1, Math.floor(rect.width - 2)), height: Math.max(1, Math.floor(rect.height - 4))
+ }}));
+ let pixels = 0;
+ for (let i = 0; i < png.data.length; i += 4) {
+ if (png.data[i] < 240 && png.data[i + 1] < 240 && png.data[i + 2] < 240) pixels++;
+ }
+ assert(pixels >= 3, selector + ' is not painted at its SVG coordinates');
+ }
+}
+
+/** Serve the fixture and repository assets on an ephemeral loopback port. */
+function createServer() {
+ const root = path.resolve(__dirname, '../..');
+ return http.createServer((request, response) => {
+ const pathname = new URL(request.url, 'http://localhost').pathname;
+ const file = path.resolve(root, '.' + pathname);
+ if (!file.startsWith(root + path.sep)) {
+ response.writeHead(403).end();
+ return;
+ }
+ fs.readFile(file, (error, data) => {
+ if (error) {
+ response.writeHead(404).end();
+ return;
+ }
+ const types = {'.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript'};
+ response.writeHead(200, {'Content-Type': types[path.extname(file)] || 'application/octet-stream'});
+ response.end(data);
+ });
+ });
+}
+
+/** Check painting and real pointer interactions before and after tree updates and transforms. */
+async function checkBrowser(name, engine, url, artifacts) {
+ const browser = await engine.launch();
+ try {
+ const page = await browser.newPage({viewport: {width: 1200, height: 800}});
+ const errors = [];
+ page.on('pageerror', error => errors.push(error.message));
+ await page.goto(url);
+ await page.waitForFunction(() => document.querySelectorAll('#networkTree g.node').length === 4);
+ // Treeviz transitions take 600 ms; wait for both them and icon fonts to settle.
+ await page.evaluate(() => document.fonts.ready);
+ await page.waitForTimeout(900);
+ await page.screenshot({path: path.join(artifacts, name + '-initial.png')});
+ for (const selector of ['.spanNetworkTree', '.netIcon', '.portBckgIcon', '.network-hw-icon', '.netCollapse']) {
+ await assertPainted(page, '#networkTree ' + selector);
+ }
+ const laptop = page.locator('.node-inner[data-mac="aa:bb:cc:dd:ee:02"] .spanNetworkTree');
+ await laptop.click();
+ assert.deepEqual(await page.evaluate(() => clicks), ['aa:bb:cc:dd:ee:02']);
+ const collapse = page.locator('.netCollapse[data-mytreemac="aa:bb:cc:dd:ee:01"]');
+ await collapse.click();
+ await page.waitForFunction(() => document.querySelectorAll('#networkTree g.node').length === 2);
+ await page.waitForTimeout(700);
+ await collapse.click();
+ await page.waitForFunction(() => document.querySelectorAll('#networkTree g.node').length === 4);
+ await page.waitForTimeout(700);
+ await assertPainted(page, '#networkTree .spanNetworkTree');
+ // Exercise Treeviz/D3's actual wheel zoom and drag pan handlers.
+ const canvas = page.locator('#networkTree svg > g');
+ const beforeZoom = await canvas.getAttribute('transform');
+ await page.mouse.move(600, 400);
+ await page.mouse.wheel(0, 300);
+ await page.waitForTimeout(500);
+ const afterZoom = await canvas.getAttribute('transform');
+ assert.notEqual(afterZoom, beforeZoom, 'Wheel zoom did not change the SVG transform');
+ await page.mouse.move(600, 400);
+ await page.mouse.down();
+ await page.mouse.move(650, 440, {steps: 5});
+ await page.mouse.up();
+ assert.notEqual(await canvas.getAttribute('transform'), afterZoom, 'Drag pan did not change the SVG transform');
+ await assertPainted(page, '#networkTree .spanNetworkTree');
+ await laptop.click();
+ assert.equal((await page.evaluate(() => clicks)).length, 2);
+ await page.screenshot({path: path.join(artifacts, name + '-zoom-pan.png')});
+ // The bundled Treeviz demo expects #tree, which network.php also does not provide.
+ assert.deepEqual(errors.filter(error => error !== 'Cannot find dom element with id:tree'), []);
+ console.log(name + ': PASS — painting, clicks, collapse/expand, zoom and pan');
+ } finally {
+ await browser.close();
+ }
+}
+
+/** Run the same rendering checks in WebKit, Chromium and Firefox without an app backend. */
+async function main() {
+ const artifacts = process.env.NETALERTX_TOPOLOGY_ARTIFACTS || path.join(os.tmpdir(), 'netalertx-topology');
+ fs.mkdirSync(artifacts, {recursive: true});
+ const server = createServer();
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
+ const url = `http://127.0.0.1:${server.address().port}/test/ui/fixtures/network_topology.html`;
+ try {
+ for (const [name, engine] of Object.entries({webkit, chromium, firefox})) {
+ await checkBrowser(name, engine, url, artifacts);
+ }
+ } finally {
+ await new Promise(resolve => server.close(resolve));
+ }
+}
+
+main().catch(error => { console.error(error); process.exitCode = 1; });