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](./img/DEVICE_MANAGEMENT/DeviceManagement_MainInfo.png) ## 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; });